From 94847956089059dc6da3e61da8c1d0d41bdeded0 Mon Sep 17 00:00:00 2001 From: chenying Date: Thu, 2 Nov 2017 15:54:57 +0800 Subject: [PATCH] Add API controller for the quotas of Karbor The quota API is intorduced to karbor using manila protect as a reference. Change-Id: I630b501445608dfa5c67ce7fdb7afa3a594d4294 Implements: blueprint support-quotas-in-karbor --- karbor/api/v1/plans.py | 35 +- karbor/api/v1/quota_classes.py | 125 +++ karbor/api/v1/quotas.py | 194 +++++ karbor/api/v1/router.py | 22 + karbor/db/api.py | 5 + karbor/db/sqlalchemy/api.py | 8 +- karbor/exception.py | 20 + karbor/policies/__init__.py | 4 + karbor/policies/quota_classes.py | 49 ++ karbor/policies/quotas.py | 71 ++ karbor/quota.py | 814 ++++++++++++++++++ .../tests/unit/api/v1/test_quota_classes.py | 57 ++ karbor/tests/unit/api/v1/test_quotas.py | 90 ++ 13 files changed, 1490 insertions(+), 4 deletions(-) create mode 100644 karbor/api/v1/quota_classes.py create mode 100644 karbor/api/v1/quotas.py create mode 100644 karbor/policies/quota_classes.py create mode 100644 karbor/policies/quotas.py create mode 100644 karbor/quota.py create mode 100644 karbor/tests/unit/api/v1/test_quota_classes.py create mode 100644 karbor/tests/unit/api/v1/test_quotas.py diff --git a/karbor/api/v1/plans.py b/karbor/api/v1/plans.py index fe1f72cc..2089f5e8 100644 --- a/karbor/api/v1/plans.py +++ b/karbor/api/v1/plans.py @@ -15,6 +15,7 @@ from oslo_config import cfg from oslo_log import log as logging from oslo_serialization import jsonutils +from oslo_utils import excutils from oslo_utils import uuidutils from webob import exc @@ -28,6 +29,7 @@ from karbor.i18n import _ from karbor import objects from karbor.objects import base as objects_base from karbor.policies import plans as plan_policy +from karbor import quota from karbor.services.operationengine import api as operationengine_api from karbor.services.protection import api as protection_api from karbor import utils @@ -44,6 +46,7 @@ query_plan_filters_opt = cfg.ListOpt('query_plan_filters', "'description']") CONF = cfg.CONF CONF.register_opt(query_plan_filters_opt) +QUOTAS = quota.QUOTAS LOG = logging.getLogger(__name__) @@ -153,7 +156,19 @@ class PlansController(wsgi.Controller): raise exc.HTTPNotFound(explanation=error.msg) context.can(plan_policy.DELETE_POLICY, target_obj=plan) + project_id = plan.project_id + try: + reserve_opts = {'plans': -1} + reservations = QUOTAS.reserve(context, + project_id=project_id, + **reserve_opts) + except Exception: + reservations = None + LOG.exception("Failed to update usages deleting plan.") plan.destroy() + if reservations: + QUOTAS.commit(context, reservations, + project_id=project_id) LOG.info("Delete plan request issued successfully.", resource={'id': plan.id}) @@ -276,8 +291,24 @@ class PlansController(wsgi.Controller): 'parameters': parameters, } - plan = objects.Plan(context=context, **plan_properties) - plan.create() + try: + reserve_opts = {'plans': 1} + reservations = QUOTAS.reserve(context, **reserve_opts) + except exception.OverQuota as e: + quota.process_reserve_over_quota( + context, e, + resource='plans') + try: + plan = objects.Plan(context=context, **plan_properties) + plan.create() + QUOTAS.commit(context, reservations) + except Exception: + with excutils.save_and_reraise_exception(): + try: + if plan and 'id' in plan: + plan.destroy() + finally: + QUOTAS.rollback(context, reservations) retval = self._view_builder.detail(req, plan) diff --git a/karbor/api/v1/quota_classes.py b/karbor/api/v1/quota_classes.py new file mode 100644 index 00000000..f58ef4c8 --- /dev/null +++ b/karbor/api/v1/quota_classes.py @@ -0,0 +1,125 @@ +# 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. + +"""The Quota Class api.""" + +from oslo_config import cfg +from oslo_log import log as logging + +from webob import exc + +from karbor.api import common +from karbor.api.openstack import wsgi +from karbor import db +from karbor import exception +from karbor.i18n import _ +from karbor.policies import quota_classes as quota_class_policy + +from karbor import quota + + +QUOTAS = quota.QUOTAS +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + + +class QuotaClassesViewBuilder(common.ViewBuilder): + """Model a Quota Class API response as a python dictionary.""" + + _collection_name = "quota_class" + + def detail_list(self, request, quota, quota_class=None): + """Detailed view of a single quota class.""" + keys = ( + 'plans', + ) + view = {key: quota.get(key) for key in keys} + if quota_class: + view['id'] = quota_class + return {self._collection_name: view} + + +class QuotaClassesController(wsgi.Controller): + """The Quota Class API controller for the OpenStack API.""" + + _view_builder_class = QuotaClassesViewBuilder + + def __init__(self): + super(QuotaClassesController, self).__init__() + + def show(self, req, id): + """Return data about the given quota class id.""" + context = req.environ['karbor.context'] + LOG.debug("Show quota class with name: %s", id, context=context) + quota_class_name = id + context.can(quota_class_policy.GET_POLICY) + try: + quota_class = QUOTAS.get_class_quotas(context, + quota_class_name) + except exception.NotAuthorized: + raise exc.HTTPForbidden() + + LOG.debug("Show quota class request issued successfully.", + resource={'id': id}) + return self._view_builder.detail_list(req, quota_class, + quota_class_name) + + def update(self, req, id, body): + context = req.environ['karbor.context'] + + LOG.info("Update quota class with name: %s", id, + context=context) + context.can(quota_class_policy.UPDATE_POLICY) + + quota_class_name = id + bad_keys = [] + for key, value in body.get('quota_class', {}).items(): + if key not in QUOTAS: + bad_keys.append(key) + continue + if key in QUOTAS and value: + try: + value = int(value) + except (ValueError, TypeError): + msg = _("Quota '%(value)s' for %(key)s should be " + "integer.") % {'value': value, 'key': key} + LOG.warning(msg) + raise exc.HTTPBadRequest(explanation=msg) + + for key in body['quota_class'].keys(): + if key in QUOTAS: + value = int(body['quota_class'][key]) + self._validate_quota_limit(value) + try: + db.quota_class_update( + context, quota_class_name, key, value) + except exception.QuotaClassNotFound: + db.quota_class_create( + context, quota_class_name, key, value) + except exception.AdminRequired: + raise exc.HTTPForbidden() + + LOG.info("Update quota class successfully.", + resource={'id': quota_class_name}) + quota_class = QUOTAS.get_class_quotas(context, id) + return self._view_builder.detail_list(req, quota_class) + + def _validate_quota_limit(self, limit): + # NOTE: -1 is a flag value for unlimited + if limit < -1: + msg = _("Quota limit must be -1 or greater.") + raise exc.HTTPBadRequest(explanation=msg) + + +def create_resource(): + return wsgi.Resource(QuotaClassesController()) diff --git a/karbor/api/v1/quotas.py b/karbor/api/v1/quotas.py new file mode 100644 index 00000000..86d7561c --- /dev/null +++ b/karbor/api/v1/quotas.py @@ -0,0 +1,194 @@ +# 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. + +"""The Quotas api.""" + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import uuidutils + +from webob import exc + +from karbor.api import common +from karbor.api.openstack import wsgi +from karbor import db +from karbor import exception +from karbor.i18n import _ +from karbor.policies import quotas as quota_policy + +from karbor import quota + + +QUOTAS = quota.QUOTAS +NON_QUOTA_KEYS = ['tenant_id', 'id'] +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + + +class QuotasViewBuilder(common.ViewBuilder): + """Model a Quotas API response as a python dictionary.""" + + _collection_name = "quota" + + def detail_list(self, request, quota, project_id=None): + """Detailed view of a single quota.""" + keys = ( + 'plans', + ) + view = {key: quota.get(key) for key in keys} + if project_id: + view['id'] = project_id + return {self._collection_name: view} + + +class QuotasController(wsgi.Controller): + """The Quotas API controller for the OpenStack API.""" + + _view_builder_class = QuotasViewBuilder + + def __init__(self): + super(QuotasController, self).__init__() + + def show(self, req, id): + """Return data about the given quota id.""" + context = req.environ['karbor.context'] + LOG.info("Show quotas with id: %s", id, context=context) + + if not uuidutils.is_uuid_like(id): + msg = _("Invalid project id provided.") + raise exc.HTTPBadRequest(explanation=msg) + context.can(quota_policy.GET_POLICY) + try: + db.authorize_project_context(context, id) + quota = self._get_quotas(context, id, usages=False) + except exception.NotAuthorized: + raise exc.HTTPForbidden() + + LOG.info("Show quotas request issued successfully.", + resource={'id': id}) + return self._view_builder.detail_list(req, quota, id) + + def detail(self, req, id): + """Return data about the given quota.""" + context = req.environ['karbor.context'] + LOG.info("Show quotas detail with id: %s", id, context=context) + + if not uuidutils.is_uuid_like(id): + msg = _("Invalid project id provided.") + raise exc.HTTPBadRequest(explanation=msg) + context.can(quota_policy.GET_POLICY) + try: + db.authorize_project_context(context, id) + quota = self._get_quotas(context, id, usages=True) + except exception.NotAuthorized: + raise exc.HTTPForbidden() + + LOG.info("Show quotas detail successfully.", + resource={'id': id}) + return self._view_builder.detail_list(req, quota, id) + + def defaults(self, req, id): + """Return data about the given quotas.""" + context = req.environ['karbor.context'] + + LOG.info("Show quotas defaults with id: %s", id, + context=context) + + if not uuidutils.is_uuid_like(id): + msg = _("Invalid project id provided.") + raise exc.HTTPBadRequest(explanation=msg) + context.can(quota_policy.GET_DEFAULT_POLICY) + quotas = QUOTAS.get_defaults(context) + + LOG.info("Show quotas defaults successfully.", + resource={'id': id}) + return self._view_builder.detail_list(req, quotas, id) + + def update(self, req, id, body): + context = req.environ['karbor.context'] + + LOG.info("Update quotas with id: %s", id, + context=context) + + if not uuidutils.is_uuid_like(id): + msg = _("Invalid project id provided.") + raise exc.HTTPBadRequest(explanation=msg) + context.can(quota_policy.UPDATE_POLICY) + + project_id = id + bad_keys = [] + for key, value in body.get('quota', {}).items(): + if (key not in QUOTAS and key not in + NON_QUOTA_KEYS): + bad_keys.append(key) + continue + if key not in NON_QUOTA_KEYS and value: + try: + value = int(value) + except (ValueError, TypeError): + msg = _("Quota '%(value)s' for %(key)s should be " + "integer.") % {'value': value, 'key': key} + LOG.warning(msg) + raise exc.HTTPBadRequest(explanation=msg) + + for key in body['quota'].keys(): + if key in QUOTAS: + value = int(body['quota'][key]) + self._validate_quota_limit(value) + try: + db.quota_update(context, project_id, key, value) + except exception.ProjectQuotaNotFound: + db.quota_create(context, project_id, key, value) + except exception.AdminRequired: + raise exc.HTTPForbidden() + + LOG.info("Update quotas successfully.", + resource={'id': project_id}) + return self._view_builder.detail_list( + req, self._get_quotas(context, id)) + + def _validate_quota_limit(self, limit): + # NOTE: -1 is a flag value for unlimited + if limit < -1: + msg = _("Quota limit must be -1 or greater.") + raise exc.HTTPBadRequest(explanation=msg) + + def _get_quotas(self, context, id, usages=False): + values = QUOTAS.get_project_quotas(context, id, usages=usages) + + if usages: + return values + else: + return dict((k, v['limit']) for k, v in values.items()) + + def delete(self, req, id): + context = req.environ['karbor.context'] + LOG.info("Delete quotas with id: %s", id, + context=context) + + if not uuidutils.is_uuid_like(id): + msg = _("Invalid project id provided.") + raise exc.HTTPBadRequest(explanation=msg) + context.can(quota_policy.DELETE_POLICY) + try: + db.authorize_project_context(context, id) + QUOTAS.destroy_all_by_project(context, id) + except exception.NotAuthorized: + raise exc.HTTPForbidden() + + LOG.info("Delete quotas successfully.", + resource={'id': id}) + + +def create_resource(): + return wsgi.Resource(QuotasController()) diff --git a/karbor/api/v1/router.py b/karbor/api/v1/router.py index 0a640dc8..32e5fe7f 100644 --- a/karbor/api/v1/router.py +++ b/karbor/api/v1/router.py @@ -17,6 +17,8 @@ from karbor.api.v1 import operation_logs from karbor.api.v1 import plans from karbor.api.v1 import protectables from karbor.api.v1 import providers +from karbor.api.v1 import quota_classes +from karbor.api.v1 import quotas from karbor.api.v1 import restores from karbor.api.v1 import scheduled_operations from karbor.api.v1 import services @@ -39,6 +41,8 @@ class APIRouter(base_wsgi.Router): operation_log_resources = operation_logs.create_resource() verification_resources = verifications.create_resource() service_resources = services.create_resource() + quota_resources = quotas.create_resource() + quota_class_resources = quota_classes.create_resource() mapper.resource("plan", "plans", controller=plans_resources, @@ -110,4 +114,22 @@ class APIRouter(base_wsgi.Router): controller=service_resources, collection={}, member={'action': 'POST'}) + mapper.resource("quota", "quotas", + controller=quota_resources, + collection={}, + member={'action': 'POST'}) + mapper.connect("quota", + "/{project_id}/quotas/{id}/defaults", + controller=quota_resources, + action='defaults', + conditions={"method": ['GET']}) + mapper.connect("quota", + "/{project_id}/quotas/{id}/detail", + controller=quota_resources, + action='detail', + conditions={"method": ['GET']}) + mapper.resource("quota_class", "quota_classes", + controller=quota_class_resources, + collection={}, + member={'action': 'POST'}) super(APIRouter, self).__init__(mapper) diff --git a/karbor/db/api.py b/karbor/db/api.py index 978919a9..cd29802c 100644 --- a/karbor/db/api.py +++ b/karbor/db/api.py @@ -816,3 +816,8 @@ def reservation_expire(context): ################### + + +def authorize_project_context(context, project_id): + """Ensures a request has permission to access the given project.""" + return IMPL.authorize_project_context(context, project_id) diff --git a/karbor/db/sqlalchemy/api.py b/karbor/db/sqlalchemy/api.py index 135eedad..82966286 100644 --- a/karbor/db/sqlalchemy/api.py +++ b/karbor/db/sqlalchemy/api.py @@ -2154,7 +2154,9 @@ def quota_usage_create(context, project_id, resource, in_use, reserved, quota_usage_ref.until_refresh = until_refresh if not session: session = get_session() - with session.begin(): + with session.begin(): + quota_usage_ref.save(session=session) + else: quota_usage_ref.save(session=session) return quota_usage_ref @@ -2202,7 +2204,9 @@ def reservation_create(context, uuid, usage, project_id, resource, delta, reservation_ref.expire = expire if not session: session = get_session() - with session.begin(): + with session.begin(): + reservation_ref.save(session=session) + else: reservation_ref.save(session=session) return reservation_ref diff --git a/karbor/exception.py b/karbor/exception.py index d5b4ab11..76de7246 100644 --- a/karbor/exception.py +++ b/karbor/exception.py @@ -425,3 +425,23 @@ class OverQuota(KarborException): class InvalidReservationExpiration(Invalid): message = _("Invalid reservation expiration %(expire)s.") + + +class InvalidQuotaValue(Invalid): + message = _("Change would make usage less than 0 for the following " + "resources: %(unders)s.") + + +class QuotaError(KarborException): + message = _("Quota exceeded: code=%(code)s") + code = 413 + headers = {'Retry-After': '0'} + safe = True + + +class PlanLimitExceeded(QuotaError): + message = _("Maximum number of plans allowed (%(allowed)d) exceeded") + + +class UnexpectedOverQuota(QuotaError): + message = _("Unexpected over quota on %(name)s.") diff --git a/karbor/policies/__init__.py b/karbor/policies/__init__.py index 117e120e..36f710dd 100644 --- a/karbor/policies/__init__.py +++ b/karbor/policies/__init__.py @@ -19,6 +19,8 @@ from karbor.policies import operation_logs from karbor.policies import plans from karbor.policies import protectables from karbor.policies import providers +from karbor.policies import quota_classes +from karbor.policies import quotas from karbor.policies import restores from karbor.policies import scheduled_operations from karbor.policies import services @@ -38,4 +40,6 @@ def list_rules(): operation_logs.list_rules(), verifications.list_rules(), services.list_rules(), + quotas.list_rules(), + quota_classes.list_rules(), ) diff --git a/karbor/policies/quota_classes.py b/karbor/policies/quota_classes.py new file mode 100644 index 00000000..0dc1430f --- /dev/null +++ b/karbor/policies/quota_classes.py @@ -0,0 +1,49 @@ +# Copyright (c) 2017 Huawei Technologies Co., Ltd. +# 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 oslo_policy import policy + +from karbor.policies import base + + +UPDATE_POLICY = 'quota_class:update' +GET_POLICY = 'quota_class:get' + +quota_classes_policies = [ + policy.DocumentedRuleDefault( + name=UPDATE_POLICY, + check_str=base.RULE_ADMIN_API, + description='Update quota classes.', + operations=[ + { + 'method': 'PUT', + 'path': '/quota_classes/{quota_class_name}' + } + ]), + policy.DocumentedRuleDefault( + name=GET_POLICY, + check_str=base.RULE_ADMIN_OR_OWNER, + description='Get quota classes.', + operations=[ + { + 'method': 'GET', + 'path': '/quota_classes/{quota_class_name}' + } + ]), +] + + +def list_rules(): + return quota_classes_policies diff --git a/karbor/policies/quotas.py b/karbor/policies/quotas.py new file mode 100644 index 00000000..1561ba09 --- /dev/null +++ b/karbor/policies/quotas.py @@ -0,0 +1,71 @@ +# Copyright (c) 2017 Huawei Technologies Co., Ltd. +# 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 oslo_policy import policy + +from karbor.policies import base + + +UPDATE_POLICY = 'quota:update' +DELETE_POLICY = 'quota:delete' +GET_POLICY = 'quota:get' +GET_DEFAULT_POLICY = 'quota:get_default' + +quotas_policies = [ + policy.DocumentedRuleDefault( + name=UPDATE_POLICY, + check_str=base.RULE_ADMIN_API, + description='Update quotas for a project.', + operations=[ + { + 'method': 'PUT', + 'path': '/quotas/{project_id}' + } + ]), + policy.DocumentedRuleDefault( + name=DELETE_POLICY, + check_str=base.RULE_ADMIN_API, + description='Delete quotas for a project.', + operations=[ + { + 'method': 'DELETE', + 'path': '/quotas/{project_id}' + } + ]), + policy.DocumentedRuleDefault( + name=GET_POLICY, + check_str=base.RULE_ADMIN_OR_OWNER, + description='Get quotas for a project.', + operations=[ + { + 'method': 'GET', + 'path': '/quotas/{project_id}' + } + ]), + policy.DocumentedRuleDefault( + name=GET_DEFAULT_POLICY, + check_str=base.RULE_ADMIN_OR_OWNER, + description='Get default quotas for a project.', + operations=[ + { + 'method': 'GET', + 'path': '/quotas/{project_id}/defaults' + } + ]), +] + + +def list_rules(): + return quotas_policies diff --git a/karbor/quota.py b/karbor/quota.py new file mode 100644 index 00000000..5e32659e --- /dev/null +++ b/karbor/quota.py @@ -0,0 +1,814 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +"""Quotas for shares.""" + +import datetime + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import importutils +from oslo_utils import timeutils +import six + +from karbor import db +from karbor import exception +from karbor.i18n import _ + +LOG = logging.getLogger(__name__) + +quota_opts = [ + cfg.IntOpt('quota_plans', + default=50, + help='The number of volume backups allowed per project'), + cfg.IntOpt('reservation_expire', + default=86400, + help='number of seconds until a reservation expires'), + cfg.IntOpt('until_refresh', + default=0, + help='count of reservations until usage is refreshed'), + cfg.IntOpt('max_age', + default=0, + help='number of seconds between subsequent usage refreshes'), + cfg.StrOpt('quota_driver', + default='karbor.quota.DbQuotaDriver', + help='default driver to use for quota checks'), ] + +CONF = cfg.CONF +CONF.register_opts(quota_opts) + + +class DbQuotaDriver(object): + """Driver to perform necessary checks to enforce quotas and obtain + + quota information. The default driver utilizes the local + database. + """ + + def get_by_project(self, context, project_id, resource): + """Get a specific quota by project.""" + + return db.quota_get(context, project_id, resource) + + def get_by_class(self, context, quota_class, resource): + """Get a specific quota by quota class.""" + + return db.quota_class_get(context, quota_class, resource) + + def get_defaults(self, context, resources): + """Given a list of resources, retrieve the default quotas. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + """ + + quotas = {} + for resource in resources.values(): + quotas[resource.name] = resource.default + + return quotas + + def get_class_quotas(self, context, resources, quota_class, + defaults=True): + """Given a list of resources, retrieve the quotas for the given quota class. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + :param quota_class: The name of the quota class to return + quotas for. + :param defaults: If True, the default value will be reported + if there is no specific value for the + resource. + """ + + quotas = {} + class_quotas = db.quota_class_get_all_by_name(context, quota_class) + for resource in resources.values(): + if defaults or resource.name in class_quotas: + quotas[resource.name] = class_quotas.get(resource.name, + resource.default) + + return quotas + + def get_project_quotas(self, context, resources, project_id, + quota_class=None, defaults=True, + usages=True): + """Given a list of resources, retrieve the quotas for the given project. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + :param project_id: The ID of the project to return quotas for. + :param quota_class: If project_id != context.project_id, the + quota class cannot be determined. This + parameter allows it to be specified. It + will be ignored if project_id == + context.project_id. + :param defaults: If True, the quota class value (or the + default value, if there is no value from the + quota class) will be reported if there is no + specific value for the resource. + :param usages: If True, the current in_use and reserved counts + will also be returned. + """ + + quotas = {} + project_quotas = db.quota_get_all_by_project(context, project_id) + if usages: + project_usages = db.quota_usage_get_all_by_project(context, + project_id) + + # Get the quotas for the appropriate class. If the project ID + # matches the one in the context, we use the quota_class from + # the context, otherwise, we use the provided quota_class (if + # any) + if project_id == context.project_id: + quota_class = context.quota_class + if quota_class: + class_quotas = db.quota_class_get_all_by_name(context, quota_class) + else: + class_quotas = {} + + for resource in resources.values(): + # Omit default/quota class values + if not defaults and resource.name not in project_quotas: + continue + + quotas[resource.name] = dict( + limit=project_quotas.get(resource.name, + class_quotas.get(resource.name, + resource.default)), ) + + # Include usages if desired. This is optional because one + # internal consumer of this interface wants to access the + # usages directly from inside a transaction. + if usages: + usage = project_usages.get(resource.name, {}) + quotas[resource.name].update( + in_use=usage.get('in_use', 0), + reserved=usage.get('reserved', 0), ) + + return quotas + + def _get_quotas(self, context, resources, keys, has_sync, project_id=None): + """A helper method which retrieves the quotas for the specific + + resources identified by keys, and which apply to the current + context. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + :param keys: A list of the desired quotas to retrieve. + :param has_sync: If True, indicates that the resource must + have a sync attribute; if False, indicates + that the resource must NOT have a sync + attribute. + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + + # Filter resources + if has_sync: + sync_filt = lambda x: hasattr(x, 'sync') + else: + sync_filt = lambda x: not hasattr(x, 'sync') + desired = set(keys) + sub_resources = dict((k, v) for k, v in resources.items() + if k in desired and sync_filt(v)) + + # Make sure we accounted for all of them... + if len(keys) != len(sub_resources): + unknown = desired - set(sub_resources.keys()) + raise exception.QuotaResourceUnknown(unknown=sorted(unknown)) + + # Grab and return the quotas (without usages) + quotas = self.get_project_quotas(context, sub_resources, + project_id, + context.quota_class, usages=False) + + return dict((k, v['limit']) for k, v in quotas.items()) + + def limit_check(self, context, resources, values, project_id=None): + """Check simple quota limits. + + For limits--those quotas for which there is no usage + synchronization function--this method checks that a set of + proposed values are permitted by the limit restriction. + + This method will raise a QuotaResourceUnknown exception if a + given resource is unknown or if it is not a simple limit + resource. + + If any of the proposed values is over the defined quota, an + OverQuota exception will be raised with the sorted list of the + resources which are too high. Otherwise, the method returns + nothing. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + :param values: A dictionary of the values to check against the + quota. + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + + # Ensure no value is less than zero + unders = [key for key, val in values.items() if val < 0] + if unders: + raise exception.InvalidQuotaValue(unders=sorted(unders)) + + # If project_id is None, then we use the project_id in context + if project_id is None: + project_id = context.project_id + + # Get the applicable quotas + quotas = self._get_quotas(context, resources, values.keys(), + has_sync=False, project_id=project_id) + # 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 exception.OverQuota(overs=sorted(overs), quotas=quotas, + usages={}) + + def reserve(self, context, resources, deltas, expire=None, + project_id=None): + """Check quotas and reserve resources. + + For counting quotas--those quotas for which there is a usage + synchronization function--this method checks quotas against + current usage and the desired deltas. + + This method will raise a QuotaResourceUnknown exception if a + given resource is unknown or if it does not have a usage + synchronization function. + + 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 a + list of reservation UUIDs which were created. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + :param deltas: A dictionary of the proposed delta changes. + :param expire: An optional parameter specifying an expiration + time for the reservations. If it is a simple + number, it is interpreted as a number of + seconds and added to the current time; if it is + a datetime.timedelta object, it will also be + added to the current time. A datetime.datetime + object will be interpreted as the absolute + expiration time. If None is specified, the + default expiration time set by + --default-reservation-expire will be used (this + value will be treated as a number of seconds). + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + + # Set up the reservation expiration + if expire is None: + expire = CONF.reservation_expire + if isinstance(expire, six.integer_types): + expire = datetime.timedelta(seconds=expire) + if isinstance(expire, datetime.timedelta): + expire = timeutils.utcnow() + expire + if not isinstance(expire, datetime.datetime): + raise exception.InvalidReservationExpiration(expire=expire) + + # If project_id is None, then we use the project_id in context + if project_id is None: + project_id = context.project_id + + # Get the applicable quotas. + # NOTE(Vek): We're not worried about races at this point. + # Yes, the admin may be in the process of reducing + # quotas, but that's a pretty rare thing. + quotas = self._get_quotas(context, resources, deltas.keys(), + has_sync=True, project_id=project_id) + + # NOTE(Vek): Most of the work here has to be done in the DB + # API, because we have to do it in a transaction, + # which means access to the session. Since the + # session isn't available outside the DBAPI, we + # have to do the work there. + return db.quota_reserve(context, resources, quotas, deltas, expire, + CONF.until_refresh, CONF.max_age, + project_id=project_id) + + def commit(self, context, reservations, project_id=None): + """Commit reservations. + + :param context: The request context, for access checks. + :param reservations: A list of the reservation UUIDs, as + returned by the reserve() method. + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + # If project_id is None, then we use the project_id in context + if project_id is None: + project_id = context.project_id + + db.reservation_commit(context, reservations, project_id=project_id) + + def rollback(self, context, reservations, project_id=None): + """Roll back reservations. + + :param context: The request context, for access checks. + :param reservations: A list of the reservation UUIDs, as + returned by the reserve() method. + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + # If project_id is None, then we use the project_id in context + if project_id is None: + project_id = context.project_id + + db.reservation_rollback(context, reservations, project_id=project_id) + + def destroy_all_by_project(self, context, project_id): + """Destroy all quotas, usages, and reservations associated with a project. + + :param context: The request context, for access checks. + :param project_id: The ID of the project being deleted. + """ + + db.quota_destroy_all_by_project(context, project_id) + + def expire(self, context): + """Expire reservations. + + Explores all currently existing reservations and rolls back + any that have expired. + + :param context: The request context, for access checks. + """ + + db.reservation_expire(context) + + +class BaseResource(object): + """Describe a single resource for quota checking.""" + + def __init__(self, name, flag=None): + """Initializes a Resource. + + :param name: The name of the resource, i.e., "shares". + :param flag: The name of the flag or configuration option + which specifies the default value of the quota + for this resource. + """ + + self.name = name + self.flag = flag + + def quota(self, driver, context, **kwargs): + """Given a driver and context, obtain the quota for this resource. + + :param driver: A quota driver. + :param context: The request context. + :param project_id: The project to obtain the quota value for. + If not provided, it is taken from the + context. If it is given as None, no + project-specific quota will be searched + for. + :param quota_class: The quota class corresponding to the + project, or for which the quota is to be + looked up. If not provided, it is taken + from the context. If it is given as None, + no quota class-specific quota will be + searched for. Note that the quota class + defaults to the value in the context, + which may not correspond to the project if + project_id is not the same as the one in + the context. + """ + + # Get the project ID + project_id = kwargs.get('project_id', context.project_id) + + # Ditto for the quota class + quota_class = kwargs.get('quota_class', context.quota_class) + + # Look up the quota for the project + if project_id: + try: + return driver.get_by_project(context, project_id, self.name) + except exception.ProjectQuotaNotFound: + pass + + # Try for the quota class + if quota_class: + try: + return driver.get_by_class(context, quota_class, self.name) + except exception.QuotaClassNotFound: + pass + + # OK, return the default + return self.default + + @property + def default(self): + """Return the default value of the quota.""" + + return CONF[self.flag] if self.flag else -1 + + +class ReservableResource(BaseResource): + """Describe a reservable resource.""" + + def __init__(self, name, sync, flag=None): + """Initializes a ReservableResource. + + Reservable resources are those resources which directly + correspond to objects in the database, i.e., shares, gigabytes, + etc. A ReservableResource must be constructed with a usage + synchronization function, which will be called to determine the + current counts of one or more resources. + + The usage synchronization function will be passed three + arguments: an admin context, the project ID, and an opaque + session object, which should in turn be passed to the + underlying database function. Synchronization functions + should return a dictionary mapping resource names to the + current in_use count for those resources; more than one + resource and resource count may be returned. Note that + synchronization functions may be associated with more than one + ReservableResource. + + :param name: The name of the resource, i.e., "shares". + :param sync: A callable which returns a dictionary to + resynchronize the in_use count for one or more + resources, as described above. + :param flag: The name of the flag or configuration option + which specifies the default value of the quota + for this resource. + """ + + super(ReservableResource, self).__init__(name, flag=flag) + self.sync = sync + + +class AbsoluteResource(BaseResource): + """Describe a non-reservable resource.""" + + pass + + +class CountableResource(AbsoluteResource): + """Describe a resource where the counts aren't based solely on the + + project ID. + """ + + def __init__(self, name, count, flag=None): + """Initializes a CountableResource. + + Countable resources are those resources which directly + correspond to objects in the database, i.e., shares, gigabytes, + etc., but for which a count by project ID is inappropriate. A + CountableResource must be constructed with a counting + function, which will be called to determine the current counts + of the resource. + + The counting function will be passed the context, along with + the extra positional and keyword arguments that are passed to + Quota.count(). It should return an integer specifying the + count. + + Note that this counting is not performed in a transaction-safe + manner. This resource class is a temporary measure to provide + required functionality, until a better approach to solving + this problem can be evolved. + + :param name: The name of the resource, i.e., "shares". + :param count: A callable which returns the count of the + resource. The arguments passed are as described + above. + :param flag: The name of the flag or configuration option + which specifies the default value of the quota + for this resource. + """ + + super(CountableResource, self).__init__(name, flag=flag) + self.count = count + + +class QuotaEngine(object): + """Represent the set of recognized quotas.""" + + def __init__(self, quota_driver_class=None): + """Initialize a Quota object.""" + + if not quota_driver_class: + quota_driver_class = CONF.quota_driver + + if isinstance(quota_driver_class, six.string_types): + quota_driver_class = importutils.import_object(quota_driver_class) + + self._resources = {} + self._driver = quota_driver_class + + def __contains__(self, resource): + return resource in self._resources + + def register_resource(self, resource): + """Register a resource.""" + + self._resources[resource.name] = resource + + def register_resources(self, resources): + """Register a list of resources.""" + + for resource in resources: + self.register_resource(resource) + + def get_by_project(self, context, project_id, resource): + """Get a specific quota by project.""" + + return self._driver.get_by_project(context, project_id, resource) + + def get_by_class(self, context, quota_class, resource): + """Get a specific quota by quota class.""" + + return self._driver.get_by_class(context, quota_class, resource) + + def get_defaults(self, context): + """Retrieve the default quotas. + + :param context: The request context, for access checks. + """ + + return self._driver.get_defaults(context, self._resources) + + def get_class_quotas(self, context, quota_class, defaults=True): + """Retrieve the quotas for the given quota class. + + :param context: The request context, for access checks. + :param quota_class: The name of the quota class to return + quotas for. + :param defaults: If True, the default value will be reported + if there is no specific value for the + resource. + """ + + return self._driver.get_class_quotas(context, self._resources, + quota_class, defaults=defaults) + + def get_project_quotas(self, context, project_id, quota_class=None, + defaults=True, usages=True): + """Retrieve the quotas for the given project. + + :param context: The request context, for access checks. + :param project_id: The ID of the project to return quotas for. + :param quota_class: If project_id != context.project_id, the + quota class cannot be determined. This + parameter allows it to be specified. + :param defaults: If True, the quota class value (or the + default value, if there is no value from the + quota class) will be reported if there is no + specific value for the resource. + :param usages: If True, the current in_use and reserved counts + will also be returned. + """ + + return self._driver.get_project_quotas(context, self._resources, + project_id, + quota_class=quota_class, + defaults=defaults, + usages=usages) + + def count(self, context, resource, *args, **kwargs): + """Count a resource. + + For countable resources, invokes the count() function and + returns its result. Arguments following the context and + resource are passed directly to the count function declared by + the resource. + + :param context: The request context, for access checks. + :param resource: The name of the resource, as a string. + """ + + # Get the resource + res = self._resources.get(resource) + if not res or not hasattr(res, 'count'): + raise exception.QuotaResourceUnknown(unknown=[resource]) + + return res.count(context, *args, **kwargs) + + def limit_check(self, context, project_id=None, **values): + """Check simple quota limits. + + For limits--those quotas for which there is no usage + synchronization function--this method checks that a set of + proposed values are permitted by the limit restriction. The + values to check are given as keyword arguments, where the key + identifies the specific quota limit to check, and the value is + the proposed value. + + This method will raise a QuotaResourceUnknown exception if a + given resource is unknown or if it is not a simple limit + resource. + + If any of the proposed values is over the defined quota, an + OverQuota exception will be raised with the sorted list of the + resources which are too high. Otherwise, the method returns + nothing. + + :param context: The request context, for access checks. + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + + return self._driver.limit_check(context, self._resources, values, + project_id=project_id) + + def reserve(self, context, expire=None, project_id=None, **deltas): + """Check quotas and reserve resources. + + For counting quotas--those quotas for which there is a usage + synchronization function--this method checks quotas against + current usage and the desired deltas. The deltas are given as + keyword arguments, and current usage and other reservations + are factored into the quota check. + + This method will raise a QuotaResourceUnknown exception if a + given resource is unknown or if it does not have a usage + synchronization function. + + 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 a + list of reservation UUIDs which were created. + + :param context: The request context, for access checks. + :param expire: An optional parameter specifying an expiration + time for the reservations. If it is a simple + number, it is interpreted as a number of + seconds and added to the current time; if it is + a datetime.timedelta object, it will also be + added to the current time. A datetime.datetime + object will be interpreted as the absolute + expiration time. If None is specified, the + default expiration time set by + --default-reservation-expire will be used (this + value will be treated as a number of seconds). + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + + reservations = self._driver.reserve(context, self._resources, deltas, + expire=expire, + project_id=project_id) + + LOG.debug(_("Created reservations %(reservations)s") % + {"reservations": reservations}) + + return reservations + + def commit(self, context, reservations, project_id=None): + """Commit reservations. + + :param context: The request context, for access checks. + :param reservations: A list of the reservation UUIDs, as + returned by the reserve() method. + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + + try: + self._driver.commit(context, reservations, project_id=project_id) + except Exception: + # NOTE(Vek): Ignoring exceptions here is safe, because the + # usage resynchronization and the reservation expiration + # mechanisms will resolve the issue. The exception is + # logged, however, because this is less than optimal. + LOG.exception(_("Failed to commit reservations " + "%(reservations)s") % + {"reservations": reservations}) + + def rollback(self, context, reservations, project_id=None): + """Roll back reservations. + + :param context: The request context, for access checks. + :param reservations: A list of the reservation UUIDs, as + returned by the reserve() method. + :param project_id: Specify the project_id if current context + is admin and admin wants to impact on + common user's tenant. + """ + + try: + self._driver.rollback(context, reservations, project_id=project_id) + except Exception: + # NOTE(Vek): Ignoring exceptions here is safe, because the + # usage resynchronization and the reservation expiration + # mechanisms will resolve the issue. The exception is + # logged, however, because this is less than optimal. + LOG.exception(_("Failed to roll back reservations " + "%(reservations)s") % + {"reservations": reservations}) + + def destroy_all_by_project(self, context, project_id): + """Destroy all quotas, usages, and reservations associated with a + + project. + + :param context: The request context, for access checks. + :param project_id: The ID of the project being deleted. + """ + + self._driver.destroy_all_by_project(context, project_id) + + def expire(self, context): + """Expire reservations. + + Explores all currently existing reservations and rolls back + any that have expired. + + :param context: The request context, for access checks. + """ + + self._driver.expire(context) + + @property + def resources(self): + return sorted(self._resources.keys()) + + +QUOTAS = QuotaEngine() + + +resources = [ + ReservableResource('plans', None, + 'quota_plans'), +] + + +QUOTAS.register_resources(resources) + + +OVER_QUOTA_RESOURCE_EXCEPTIONS = {'plans': exception.PlanLimitExceeded} + + +def process_reserve_over_quota(context, over_quota_exception, + resource, size=None): + """Handle OverQuota exception. + + Analyze OverQuota exception, and raise new exception related to + resource type. If there are unexpected items in overs, + UnexpectedOverQuota is raised. + + :param context: security context + :param over_quota_exception: OverQuota exception + :param resource: can be backups, snapshots, and volumes + :param size: requested size in reservation + """ + def _consumed(name): + return usages[name]['reserved'] + usages[name]['in_use'] + + overs = over_quota_exception.kwargs['overs'] + usages = over_quota_exception.kwargs['usages'] + quotas = over_quota_exception.kwargs['quotas'] + invalid_overs = [] + + for over in overs: + if (resource in OVER_QUOTA_RESOURCE_EXCEPTIONS.keys() and + resource in over): + msg = ("Quota exceeded for %(s_pid)s, tried to create " + "%(s_resource)s (%(d_consumed)d %(s_resource)ss " + "already consumed).") + LOG.warning(msg, {'s_pid': context.project_id, + 'd_consumed': _consumed(over), + 's_resource': resource[:-1]}) + raise OVER_QUOTA_RESOURCE_EXCEPTIONS[resource]( + allowed=quotas[over], + name=over) + invalid_overs.append(over) + + if invalid_overs: + raise exception.UnexpectedOverQuota(name=', '.join(invalid_overs)) diff --git a/karbor/tests/unit/api/v1/test_quota_classes.py b/karbor/tests/unit/api/v1/test_quota_classes.py new file mode 100644 index 00000000..5323b9a1 --- /dev/null +++ b/karbor/tests/unit/api/v1/test_quota_classes.py @@ -0,0 +1,57 @@ +# 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 mock +from oslo_config import cfg + +from karbor.api.v1 import quota_classes +from karbor import context +from karbor.tests import base +from karbor.tests.unit.api import fakes + +CONF = cfg.CONF + + +class QuotaClassApiTest(base.TestCase): + def setUp(self): + super(QuotaClassApiTest, self).setUp() + self.controller = quota_classes.QuotaClassesController() + self.ctxt = context.RequestContext('demo', 'fakeproject', True) + + @mock.patch( + 'karbor.db.sqlalchemy.api.quota_class_update') + def test_quota_update(self, mock_quota_update): + quota_class = self._quota_in_request_body() + body = {"quota_class": quota_class} + req = fakes.HTTPRequest.blank( + '/v1/quota_classes/73f74f90a1754bd7ad658afb3272323f', + use_admin_context=True) + self.controller.update( + req, '73f74f90a1754bd7ad658afb3272323f', body) + self.assertTrue(mock_quota_update.called) + + @mock.patch( + 'karbor.quota.DbQuotaDriver.get_class_quotas') + def test_quota_show(self, moak_quota_get): + req = fakes.HTTPRequest.blank( + '/v1/quota_classes/73f74f90a1754bd7ad658afb3272323f', + use_admin_context=True) + self.controller.show( + req, '73f74f90a1754bd7ad658afb3272323f') + self.assertTrue(moak_quota_get.called) + + def _quota_in_request_body(self): + quota_req = { + "plans": 20, + } + return quota_req diff --git a/karbor/tests/unit/api/v1/test_quotas.py b/karbor/tests/unit/api/v1/test_quotas.py new file mode 100644 index 00000000..cd18ca67 --- /dev/null +++ b/karbor/tests/unit/api/v1/test_quotas.py @@ -0,0 +1,90 @@ +# 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 mock +from oslo_config import cfg +from webob import exc + +from karbor.api.v1 import quotas +from karbor import context +from karbor.tests import base +from karbor.tests.unit.api import fakes + +CONF = cfg.CONF + + +class QuotaApiTest(base.TestCase): + def setUp(self): + super(QuotaApiTest, self).setUp() + self.controller = quotas.QuotasController() + self.ctxt = context.RequestContext('demo', 'fakeproject', True) + + @mock.patch( + 'karbor.db.sqlalchemy.api.quota_update') + def test_quota_update(self, mock_quota_update): + quota = self._quota_in_request_body() + body = {"quota": quota} + req = fakes.HTTPRequest.blank( + '/v1/quotas/73f74f90a1754bd7ad658afb3272323f', + use_admin_context=True) + self.controller.update( + req, '73f74f90a1754bd7ad658afb3272323f', body) + self.assertTrue(mock_quota_update.called) + + def test_quota_update_invalid_project_id(self): + quota = self._quota_in_request_body() + body = {"quota": quota} + req = fakes.HTTPRequest.blank( + '/v1/quotas/111', use_admin_context=True) + self.assertRaises(exc.HTTPBadRequest, self.controller.update, + req, '111', body) + + @mock.patch( + 'karbor.quota.DbQuotaDriver.get_project_quotas') + def test_quota_show(self, moak_quota_get): + req = fakes.HTTPRequest.blank( + '/v1/quotas/73f74f90a1754bd7ad658afb3272323f', + use_admin_context=True) + self.controller.show( + req, '73f74f90a1754bd7ad658afb3272323f') + self.assertTrue(moak_quota_get.called) + + def test_quota_show_invalid(self): + req = fakes.HTTPRequest.blank('/v1/quotas/1', + use_admin_context=True) + self.assertRaises( + exc.HTTPBadRequest, self.controller.show, + req, "1") + + @mock.patch( + 'karbor.quota.DbQuotaDriver.destroy_all_by_project') + def test_quota_delete(self, moak_restore_get): + req = fakes.HTTPRequest.blank( + '/v1/quotas/73f74f90a1754bd7ad658afb3272323f', + use_admin_context=True) + self.controller.delete( + req, '73f74f90a1754bd7ad658afb3272323f') + self.assertTrue(moak_restore_get.called) + + def test_quota_delete_invalid(self): + req = fakes.HTTPRequest.blank('/v1/quotas/1', + use_admin_context=True) + self.assertRaises( + exc.HTTPBadRequest, self.controller.delete, + req, "1") + + def _quota_in_request_body(self): + quota_req = { + "plans": 20, + } + return quota_req