From 1da8d2a473130d2a026abe26daae64a98d45d4e8 Mon Sep 17 00:00:00 2001 From: Chaoyi Huang Date: Thu, 21 Jan 2016 17:18:12 +0800 Subject: [PATCH] Quota management for Nova-APIGW and Cinder-APIGW(part1) Port the models and db access code mostly from Cinder, for Cinder has implemented for hierarchy multi-tenancy quota management The quota management and control in Tricircle is described in the design doc: https://docs.google.com/document/d/18kZZ1snMOCD9IQvUKI5NVDzSASpw-QKj7l2zNqMEd3g/ BP: https://blueprints.launchpad.net/tricircle/+spec/implement-stateless Change-Id: I90d210f2154d8f68be49cc4fc4a62f2532cb5f92 Signed-off-by: Chaoyi Huang --- tricircle/common/context.py | 12 + tricircle/common/exceptions.py | 46 +- tricircle/common/quota.py | 194 ++++++ tricircle/db/api.py | 620 ++++++++++++++++++ tricircle/db/core.py | 24 +- .../db/migrate_repo/versions/002_resource.py | 71 +- tricircle/db/models.py | 119 +++- tricircle/tests/unit/db/test_api.py | 326 ++++++++- tricircle/tests/unit/db/test_models.py | 8 +- 9 files changed, 1394 insertions(+), 26 deletions(-) create mode 100644 tricircle/common/quota.py diff --git a/tricircle/common/context.py b/tricircle/common/context.py index d1a7da8..80535e0 100644 --- a/tricircle/common/context.py +++ b/tricircle/common/context.py @@ -13,6 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. +import copy + from pecan import request import oslo_context.context as oslo_ctx @@ -119,3 +121,13 @@ class Context(ContextBase): if not self._session: self._session = core.get_session() return self._session + + def elevated(self, read_deleted=None, overwrite=False): + """Return a version of this context with admin flag set.""" + ctx = copy.copy(self) + ctx.is_admin = True + + if read_deleted is not None: + ctx.read_deleted = read_deleted + + return ctx diff --git a/tricircle/common/exceptions.py b/tricircle/common/exceptions.py index 31249fc..76fe24e 100644 --- a/tricircle/common/exceptions.py +++ b/tricircle/common/exceptions.py @@ -56,7 +56,9 @@ class BadRequest(TricircleException): class NotFound(TricircleException): - pass + message = _("Resource could not be found.") + code = 404 + safe = True class Conflict(TricircleException): @@ -120,3 +122,45 @@ class ResourceNotSupported(TricircleException): def __init__(self, resource, method): super(ResourceNotSupported, self).__init__(resource=resource, method=method) + + +class Invalid(TricircleException): + message = _("Unacceptable parameters.") + code = 400 + + +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 QuotaNotFound(NotFound): + message = _("Quota could not be found") + + +class QuotaResourceUnknown(QuotaNotFound): + message = _("Unknown quota resources %(unknown)s.") + + +class ProjectQuotaNotFound(QuotaNotFound): + message = _("Quota for project %(project_id)s could not be found.") + + +class QuotaClassNotFound(QuotaNotFound): + message = _("Quota class %(class_name)s could not be found.") + + +class QuotaUsageNotFound(QuotaNotFound): + message = _("Quota usage for project %(project_id)s could not be found.") + + +class ReservationNotFound(QuotaNotFound): + message = _("Quota reservation %(uuid)s could not be found.") + + +class OverQuota(TricircleException): + message = _("Quota exceeded for resources: %(overs)s") diff --git a/tricircle/common/quota.py b/tricircle/common/quota.py new file mode 100644 index 0000000..029b118 --- /dev/null +++ b/tricircle/common/quota.py @@ -0,0 +1,194 @@ +# Copyright 2015 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. + +""" +Routines for configuring tricircle, largely copy from Neutron +""" + +from oslo_config import cfg +import oslo_log.log as logging + +from tricircle.common import exceptions + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +class BaseResource(object): + """Describe a single resource for quota checking.""" + + def __init__(self, name, flag=None, parent_project_id=None): + """Initializes a Resource. + + :param name: The name of the resource, i.e., "volumes". + :param flag: The name of the flag or configuration option + which specifies the default value of the quota + for this resource. + :param parent_project_id: The id of the current project's parent, + if any. + """ + + self.name = name + self.flag = flag + self.parent_project_id = parent_project_id + + 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 exceptions.ProjectQuotaNotFound: + pass + + # Try for the quota class + if quota_class: + try: + return driver.get_by_class(context, quota_class, self.name) + except exceptions.QuotaClassNotFound: + pass + + # OK, return the default + return driver.get_default(context, self, + parent_project_id=self.parent_project_id) + + @property + def default(self): + """Return the default value of the quota.""" + + if self.parent_project_id: + return 0 + + 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., volumes, 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., "volumes". + :param sync: A dbapi methods name 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) + if sync: + self.sync = sync + + +class AbsoluteResource(BaseResource): + """Describe a non-reservable resource.""" + + pass + + +class CountableResource(AbsoluteResource): + """Describe a resource where counts aren't based only 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., volumes, 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., "volumes". + :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 VolumeTypeResource(ReservableResource): + """ReservableResource for a specific volume type.""" + + def __init__(self, part_name, volume_type): + """Initializes a VolumeTypeResource. + + :param part_name: The kind of resource, i.e., "volumes". + :param volume_type: The volume type for this resource. + """ + + self.volume_type_name = volume_type['name'] + self.volume_type_id = volume_type['id'] + name = "%s_%s" % (part_name, self.volume_type_name) + super(VolumeTypeResource, self).__init__(name, "_sync_%s" % part_name) diff --git a/tricircle/db/api.py b/tricircle/db/api.py index 490e495..dc54be0 100644 --- a/tricircle/db/api.py +++ b/tricircle/db/api.py @@ -13,11 +13,28 @@ # License for the specific language governing permissions and limitations # under the License. +import functools +import time +import uuid + +from oslo_config import cfg +from oslo_db import exception as db_exc +from oslo_log import log as logging +from oslo_utils import timeutils + +from tricircle.common.context import is_admin_context as _is_admin_context +from tricircle.common import exceptions +from tricircle.common.i18n import _ +from tricircle.common.i18n import _LW from tricircle.db import core from tricircle.db import models +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + def create_pod(context, pod_dict): with context.session.begin(): return core.create_resource(context, models.Pod, pod_dict) @@ -166,3 +183,606 @@ def get_pod_by_name(context, pod_name): return pod return None + + +_DEFAULT_QUOTA_NAME = 'default' + + +def _is_user_context(context): + """Indicates if the request context is a normal user.""" + if not context: + return False + if context.is_admin: + return False + if not context.user_id or not context.project_id: + return False + return True + + +def authorize_quota_class_context(context, class_name): + """Ensures a request has permission to access the given quota class.""" + if _is_user_context(context): + if not context.quota_class: + raise exceptions.NotAuthorized() + elif context.quota_class != class_name: + raise exceptions.NotAuthorized() + + +def authorize_project_context(context, project_id): + """Ensures a request has permission to access the given project.""" + if _is_user_context(context): + if not context.project_id: + raise exceptions.NotAuthorized() + elif context.project_id != project_id: + raise exceptions.NotAuthorized() + + +def authorize_user_context(context, user_id): + """Ensures a request has permission to access the given user.""" + if _is_user_context(context): + if not context.user_id: + raise exceptions.NotAuthorized() + elif context.user_id != user_id: + raise exceptions.NotAuthorized() + + +def require_admin_context(f): + """Decorator to require admin request context. + + The first argument to the wrapped function must be the context. + + """ + + def wrapper(*args, **kwargs): + if not _is_admin_context(args[0]): + raise exceptions.AdminRequired() + return f(*args, **kwargs) + return wrapper + + +def require_context(f): + """Decorator to require *any* user or admin context. + + This does no authorization for user or project access matching, see + :py:func:`authorize_project_context` and + :py:func:`authorize_user_context`. + + The first argument to the wrapped function must be the context. + + """ + + def wrapper(*args, **kwargs): + if not _is_admin_context(args[0]) and not _is_user_context(args[0]): + raise exceptions.NotAuthorized() + return f(*args, **kwargs) + return wrapper + + +def _retry_on_deadlock(f): + """Decorator to retry a DB API call if Deadlock was received.""" + @functools.wraps(f) + def wrapped(*args, **kwargs): + while True: + try: + return f(*args, **kwargs) + except db_exc.DBDeadlock: + LOG.warning(_LW("Deadlock detected when running " + "'%(func_name)s': Retrying..."), + dict(func_name=f.__name__)) + # Retry! + time.sleep(0.5) + continue + functools.update_wrapper(wrapped, f) + return wrapped + + +def model_query(context, *args, **kwargs): + """Query helper that accounts for context's `read_deleted` field. + + :param context: context to query under + :param session: if present, the session to use + :param read_deleted: if present, overrides context's read_deleted field. + :param project_only: if present and context is user-type, then restrict + query to match the context's project_id. + """ + session = kwargs.get('session') or context.session + read_deleted = kwargs.get('read_deleted') or context.read_deleted + project_only = kwargs.get('project_only') + + query = session.query(*args) + + if read_deleted == 'no': + query = query.filter_by(deleted=False) + elif read_deleted == 'yes': + pass # omit the filter to include deleted and active + elif read_deleted == 'only': + query = query.filter_by(deleted=True) + elif read_deleted == 'int_no': + query = query.filter_by(deleted=0) + else: + raise Exception( + _("Unrecognized read_deleted value '%s'") % read_deleted) + + if project_only and _is_user_context(context): + query = query.filter_by(project_id=context.project_id) + + return query + + +@require_context +def _quota_get(context, project_id, resource, session=None): + result = model_query(context, models.Quotas, session=session, + read_deleted="no").\ + filter_by(project_id=project_id).\ + filter_by(resource=resource).\ + first() + + if not result: + raise exceptions.ProjectQuotaNotFound(project_id=project_id) + + return result + + +@require_context +def quota_get(context, project_id, resource): + return _quota_get(context, project_id, resource) + + +@require_context +def quota_get_all_by_project(context, project_id): + authorize_project_context(context, project_id) + + rows = model_query(context, models.Quotas, read_deleted="no").\ + filter_by(project_id=project_id).\ + all() + + result = {'project_id': project_id} + for row in rows: + result[row.resource] = row.hard_limit + + return result + + +@require_context +def quota_allocated_get_all_by_project(context, project_id): + rows = model_query(context, models.Quotas, read_deleted='no').filter_by( + project_id=project_id).all() + result = {'project_id': project_id} + for row in rows: + result[row.resource] = row.allocated + return result + + +@require_admin_context +def quota_create(context, project_id, resource, limit, allocated=0): + quota_ref = models.Quotas() + quota_ref.project_id = project_id + quota_ref.resource = resource + quota_ref.hard_limit = limit + if allocated: + quota_ref.allocated = allocated + + session = core.get_session() + with session.begin(): + quota_ref.save(session) + return quota_ref + + +@require_admin_context +def quota_update(context, project_id, resource, limit): + with context.session.begin(): + quota_ref = _quota_get(context, project_id, resource, + session=context.session) + quota_ref.hard_limit = limit + return quota_ref + + +@require_admin_context +def quota_allocated_update(context, project_id, resource, allocated): + with context.session.begin(): + quota_ref = _quota_get(context, project_id, resource, + session=context.session) + quota_ref.allocated = allocated + return quota_ref + + +@require_admin_context +def quota_destroy(context, project_id, resource): + with context.session.begin(): + quota_ref = _quota_get(context, project_id, resource, + session=context.session) + quota_ref.delete(session=context.session) + + +@require_context +def _quota_class_get(context, class_name, resource, session=None): + result = model_query(context, models.QuotaClasses, session=session, + read_deleted="no").\ + filter_by(class_name=class_name).\ + filter_by(resource=resource).\ + first() + + if not result: + raise exceptions.QuotaClassNotFound(class_name=class_name) + + return result + + +@require_context +def quota_class_get(context, class_name, resource): + return _quota_class_get(context, class_name, resource) + + +def quota_class_get_default(context): + rows = model_query(context, models.QuotaClasses, + read_deleted="no").\ + filter_by(class_name=_DEFAULT_QUOTA_NAME).all() + + result = {'class_name': _DEFAULT_QUOTA_NAME} + for row in rows: + result[row.resource] = row.hard_limit + + return result + + +@require_context +def quota_class_get_all_by_name(context, class_name): + authorize_quota_class_context(context, class_name) + + rows = model_query(context, models.QuotaClasses, read_deleted="no").\ + filter_by(class_name=class_name).\ + all() + + result = {'class_name': class_name} + for row in rows: + result[row.resource] = row.hard_limit + + return result + + +@require_admin_context +def quota_class_create(context, class_name, resource, limit): + quota_class_ref = models.QuotaClasses() + quota_class_ref.class_name = class_name + quota_class_ref.resource = resource + quota_class_ref.hard_limit = limit + + session = core.get_session() + with session.begin(): + quota_class_ref.save(session) + return quota_class_ref + + +@require_admin_context +def quota_class_update(context, class_name, resource, limit): + with context.session.begin(): + quota_class_ref = _quota_class_get(context, class_name, resource, + session=context.session) + quota_class_ref.hard_limit = limit + + return quota_class_ref + + +@require_admin_context +def quota_class_destroy(context, class_name, resource): + with context.session.begin(): + quota_class_ref = _quota_class_get(context, class_name, resource, + session=context.session) + quota_class_ref.delete(session=context.session) + + +@require_admin_context +def quota_class_destroy_all_by_name(context, class_name): + with context.session.begin(): + quota_classes = model_query(context, models.QuotaClasses, + session=context.session, + read_deleted="no").\ + filter_by(class_name=class_name).\ + all() + + for quota_class_ref in quota_classes: + quota_class_ref.delete(session=context.session) + + +@require_context +def quota_usage_get(context, project_id, resource): + result = model_query(context, models.QuotaUsages, read_deleted="no").\ + filter_by(project_id=project_id).\ + filter_by(resource=resource).\ + first() + + if not result: + raise exceptions.QuotaUsageNotFound(project_id=project_id) + + return result + + +@require_context +def quota_usage_get_all_by_project(context, project_id): + authorize_project_context(context, project_id) + + rows = model_query(context, models.QuotaUsages, read_deleted="no").\ + filter_by(project_id=project_id).\ + all() + + result = {'project_id': project_id} + for row in rows: + result[row.resource] = dict(in_use=row.in_use, reserved=row.reserved) + + return result + + +@require_admin_context +def _quota_usage_create(context, project_id, resource, in_use, reserved, + until_refresh, session=None): + + quota_usage_ref = models.QuotaUsages() + quota_usage_ref.project_id = project_id + quota_usage_ref.resource = resource + quota_usage_ref.in_use = in_use + quota_usage_ref.reserved = reserved + quota_usage_ref.until_refresh = until_refresh + quota_usage_ref.save(session=session) + + return quota_usage_ref + + +def _reservation_create(context, uuid, usage, project_id, resource, delta, + expire, session=None): + reservation_ref = models.Reservation() + reservation_ref.uuid = uuid + reservation_ref.usage_id = usage['id'] + reservation_ref.project_id = project_id + reservation_ref.resource = resource + reservation_ref.delta = delta + reservation_ref.expire = expire + reservation_ref.save(session=session) + + return reservation_ref + + +# NOTE(johannes): The quota code uses SQL locking to ensure races don't +# cause under or over counting of resources. To avoid deadlocks, this +# code always acquires the lock on quota_usages before acquiring the lock +# on reservations. + +def _get_quota_usages(context, session, project_id): + # Broken out for testability + rows = model_query(context, models.QuotaUsages, + read_deleted="no", + session=session).\ + filter_by(project_id=project_id).\ + with_lockmode('update').\ + all() + return {row.resource: row for row in rows} + + +def _get_quota_usages_by_resource(context, session, project_id, resource): + # TODO(joehuang), add user_id as part of the filter + rows = model_query(context, models.QuotaUsages, + read_deleted="no", + session=session).\ + filter_by(project_id=project_id).\ + filter_by(resource=resource).\ + with_lockmode('update').\ + all() + return {row.resource: row for row in rows} + + +@require_context +@_retry_on_deadlock +def quota_reserve(context, resources, quotas, deltas, expire, + until_refresh, max_age, project_id=None): + elevated = context.elevated() + with context.session.begin(): + if project_id is None: + project_id = context.project_id + + # Get the current usages + usages = _get_quota_usages(context, context.session, project_id) + + # Handle usage refresh + refresh = False + work = set(deltas.keys()) + while work: + resource = work.pop() + + # Do we need to refresh the usage? + if resource not in usages: + usages[resource] = _quota_usage_create(elevated, + project_id, + resource, + 0, 0, + until_refresh or None, + session=context.session) + refresh = True + elif usages[resource].in_use < 0: + # Negative in_use count indicates a desync, so try to + # heal from that... + refresh = True + elif usages[resource].until_refresh is not None: + usages[resource].until_refresh -= 1 + if usages[resource].until_refresh <= 0: + refresh = True + elif max_age and usages[resource].updated_at is not None and ( + (usages[resource].updated_at - + timeutils.utcnow()).seconds >= max_age): + refresh = True + + if refresh: + # refresh from the bottom pod + pass + + # Check for deltas that would go negative + unders = [r for r, delta in deltas.items() + if delta < 0 and delta + usages[r].in_use < 0] + + # Now, let's check the quotas + # NOTE(Vek): We're only concerned about positive increments. + # If a project has gone over quota, we want them to + # be able to reduce their usage without any + # problems. + overs = [r for r, delta in deltas.items() + if quotas[r] >= 0 and delta >= 0 and + quotas[r] < delta + usages[r].in_use + usages[r].reserved] + + # NOTE(Vek): The quota check needs to be in the transaction, + # but the transaction doesn't fail just because + # we're over quota, so the OverQuota raise is + # outside the transaction. If we did the raise + # here, our usage updates would be discarded, but + # they're not invalidated by being over-quota. + + # Create the reservations + if not overs: + reservations = [] + for resource, delta in deltas.items(): + reservation = _reservation_create(elevated, + str(uuid.uuid4()), + usages[resource], + project_id, + resource, delta, expire, + session=context.session) + reservations.append(reservation.uuid) + + # Also update the reserved quantity + # NOTE(Vek): Again, we are only concerned here about + # positive increments. Here, though, we're + # worried about the following scenario: + # + # 1) User initiates resize down. + # 2) User allocates a new instance. + # 3) Resize down fails or is reverted. + # 4) User is now over quota. + # + # To prevent this, we only update the + # reserved value if the delta is positive. + if delta > 0: + usages[resource].reserved += delta + + if unders: + LOG.warning(_LW("Change will make usage less than 0 for the following " + "resources: %s"), unders) + if overs: + usages = {k: dict(in_use=v['in_use'], reserved=v['reserved']) + for k, v in usages.items()} + raise exceptions.OverQuota(overs=sorted(overs), quotas=quotas, + usages=usages) + + return reservations + + +def _quota_reservations(session, context, reservations): + """Return the relevant reservations.""" + + # Get the listed reservations + return model_query(context, models.Reservation, + read_deleted="no", + session=session).\ + filter(models.Reservation.uuid.in_(reservations)).\ + with_lockmode('update').\ + all() + + +@require_context +@_retry_on_deadlock +def reservation_commit(context, reservations, project_id=None): + with context.session.begin(): + usages = _get_quota_usages(context, context.session, project_id) + + for reservation in _quota_reservations(context.session, + context, + reservations): + usage = usages[reservation.resource] + if reservation.delta >= 0: + usage.reserved -= reservation.delta + usage.in_use += reservation.delta + + reservation.delete(session=context.session) + + +@require_context +@_retry_on_deadlock +def reservation_rollback(context, reservations, project_id=None): + with context.session.begin(): + usages = _get_quota_usages(context, context.session, project_id) + + for reservation in _quota_reservations(context.session, + context, + reservations): + usage = usages[reservation.resource] + if reservation.delta >= 0: + usage.reserved -= reservation.delta + + reservation.delete(session=context.session) + + +def quota_destroy_by_project(*args, **kwargs): + """Destroy all limit quotas associated with a project. + + Leaves usage and reservation quotas intact. + """ + quota_destroy_all_by_project(only_quotas=True, *args, **kwargs) + + +@require_admin_context +@_retry_on_deadlock +def quota_destroy_all_by_project(context, project_id, only_quotas=False): + """Destroy all quotas associated with a project. + + This includes limit quotas, usage quotas and reservation quotas. + Optionally can only remove limit quotas and leave other types as they are. + + :param context: The request context, for access checks. + :param project_id: The ID of the project being deleted. + :param only_quotas: Only delete limit quotas, leave other types intact. + """ + with context.session.begin(): + quotas = model_query(context, models.Quotas, session=context.session, + read_deleted="no").\ + filter_by(project_id=project_id).\ + all() + + for quota_ref in quotas: + quota_ref.delete(session=context.session) + + if only_quotas: + return + + quota_usages = model_query(context, models.QuotaUsages, + session=context.session, + read_deleted="no").\ + filter_by(project_id=project_id).\ + all() + + for quota_usage_ref in quota_usages: + quota_usage_ref.delete(session=context.session) + + reservations = model_query(context, models.Reservation, + session=context.session, + read_deleted="no").\ + filter_by(project_id=project_id).\ + all() + + for reservation_ref in reservations: + reservation_ref.delete(session=context.session) + + +@require_admin_context +@_retry_on_deadlock +def reservation_expire(context): + with context.session.begin(): + current_time = timeutils.utcnow() + results = model_query(context, models.Reservation, + session=context.session, + read_deleted="no").\ + filter(models.Reservation.expire < current_time).\ + all() + + if results: + for reservation in results: + if reservation.delta >= 0: + reservation.usage.reserved -= reservation.delta + reservation.usage.save(session=context.session) + + reservation.delete(session=context.session) diff --git a/tricircle/db/core.py b/tricircle/db/core.py index 78fbe51..8fc2712 100644 --- a/tricircle/db/core.py +++ b/tricircle/db/core.py @@ -14,13 +14,17 @@ # under the License. +import threading + +import sqlalchemy as sql +from sqlalchemy.ext import declarative +from sqlalchemy.inspection import inspect + + from oslo_config import cfg import oslo_db.options as db_options import oslo_db.sqlalchemy.session as db_session from oslo_utils import strutils -import sqlalchemy as sql -from sqlalchemy.ext import declarative -from sqlalchemy.inspection import inspect from tricircle.common import exceptions @@ -31,6 +35,7 @@ db_opts = [ ] cfg.CONF.register_opts(db_opts) +_LOCK = threading.Lock() _engine_facade = None ModelBase = declarative.declarative_base() @@ -64,12 +69,15 @@ def _filter_query(model, query, filters): def _get_engine_facade(): - global _engine_facade + global _LOCK + with _LOCK: + global _engine_facade - if not _engine_facade: - t_connection = cfg.CONF.tricircle_db_connection - _engine_facade = db_session.EngineFacade(t_connection, _conf=cfg.CONF) - return _engine_facade + if not _engine_facade: + t_connection = cfg.CONF.tricircle_db_connection + _engine_facade = db_session.EngineFacade(t_connection, + _conf=cfg.CONF) + return _engine_facade def _get_resource(context, model, pk_value): diff --git a/tricircle/db/migrate_repo/versions/002_resource.py b/tricircle/db/migrate_repo/versions/002_resource.py index 9c77116..8835d26 100644 --- a/tricircle/db/migrate_repo/versions/002_resource.py +++ b/tricircle/db/migrate_repo/versions/002_resource.py @@ -118,14 +118,72 @@ def upgrade(migrate_engine): quotas = sql.Table( 'quotas', meta, sql.Column('id', sql.Integer, primary_key=True), - sql.Column('project_id', sql.String(255)), + sql.Column('project_id', sql.String(255), index=True), + sql.Column('resource', sql.String(255), nullable=False), + sql.Column('hard_limit', sql.Integer), + sql.Column('allocated', sql.Integer, default=0), + sql.Column('created_at', sql.DateTime), + sql.Column('updated_at', sql.DateTime), + sql.Column('deleted_at', sql.DateTime), + sql.Column('deleted', sql.Integer), + migrate.UniqueConstraint( + 'project_id', 'resource', 'deleted', + name='uniq_quotas0project_id0resource0deleted'), + mysql_engine='InnoDB', + mysql_charset='utf8') + + quota_classes = sql.Table( + 'quota_classes', meta, + sql.Column('id', sql.Integer, primary_key=True), + sql.Column('class_name', sql.String(255), index=True), sql.Column('resource', sql.String(255), nullable=False), sql.Column('hard_limit', sql.Integer), sql.Column('created_at', sql.DateTime), sql.Column('updated_at', sql.DateTime), + sql.Column('deleted_at', sql.DateTime), + sql.Column('deleted', sql.Integer), migrate.UniqueConstraint( - 'project_id', 'resource', - name='uniq_quotas0project_id0resource'), + 'class_name', 'resource', 'deleted', + name='uniq_quota_classes0class_name0resource0deleted'), + mysql_engine='InnoDB', + mysql_charset='utf8') + + quota_usages = sql.Table( + 'quota_usages', meta, + sql.Column('id', sql.Integer, primary_key=True), + sql.Column('project_id', sql.String(255), index=True), + sql.Column('user_id', sql.String(255), index=True), + sql.Column('resource', sql.String(255), nullable=False), + sql.Column('in_use', sql.Integer), + sql.Column('reserved', sql.Integer), + sql.Column('until_refresh', sql.Integer), + sql.Column('created_at', sql.DateTime), + sql.Column('updated_at', sql.DateTime), + sql.Column('deleted_at', sql.DateTime), + sql.Column('deleted', sql.Integer), + mysql_engine='InnoDB', + mysql_charset='utf8') + + reservations = sql.Table( + 'reservations', meta, + sql.Column('id', sql.Integer(), primary_key=True), + sql.Column('uuid', sql.String(length=36), nullable=False), + sql.Column('usage_id', sql.Integer(), + sql.ForeignKey('quota_usages.id'), + nullable=False), + sql.Column('project_id', + sql.String(length=255), + index=True), + sql.Column('resource', + sql.String(length=255)), + sql.Column('delta', sql.Integer(), nullable=False), + sql.Column('expire', sql.DateTime), + + sql.Column('created_at', sql.DateTime), + sql.Column('updated_at', sql.DateTime), + sql.Column('deleted_at', sql.DateTime), + sql.Column('deleted', sql.Boolean(create_constraint=True, + name=None)), mysql_engine='InnoDB', mysql_charset='utf8') @@ -167,8 +225,9 @@ def upgrade(migrate_engine): tables = [aggregates, aggregate_metadata, instance_types, instance_type_projects, instance_type_extra_specs, key_pairs, - quotas, volume_types, quality_of_service_specs, - cascaded_pods_resource_routing] + quotas, quota_classes, quota_usages, reservations, + volume_types, + quality_of_service_specs, cascaded_pods_resource_routing] for table in tables: table.create() @@ -178,6 +237,8 @@ def upgrade(migrate_engine): 'references': [instance_types.c.id]}, {'columns': [instance_type_extra_specs.c.instance_type_id], 'references': [instance_types.c.id]}, + {'columns': [reservations.c.usage_id], + 'references': [quota_usages.c.id]}, {'columns': [volume_types.c.qos_specs_id], 'references': [quality_of_service_specs.c.id]}, {'columns': [quality_of_service_specs.c.specs_id], diff --git a/tricircle/db/models.py b/tricircle/db/models.py index 7c0b148..5cd5a13 100644 --- a/tricircle/db/models.py +++ b/tricircle/db/models.py @@ -13,12 +13,14 @@ # License for the specific language governing permissions and limitations # under the License. - -from oslo_db.sqlalchemy import models import sqlalchemy as sql from sqlalchemy.dialects import mysql +from sqlalchemy.orm import relationship from sqlalchemy import schema +from oslo_db.sqlalchemy import models +from oslo_utils import timeutils + from tricircle.db import core @@ -143,7 +145,26 @@ class KeyPair(core.ModelBase, core.DictBase, models.TimestampMixin): nullable=False, server_default='ssh') -class Quota(core.ModelBase, core.DictBase, models.TimestampMixin): +# Quota part are ported from Cinder for hierarchy multi-tenancy quota control +class QuotasBase(models.ModelBase, core.DictBase, + models.TimestampMixin, models.SoftDeleteMixin): + """QuotasBase. + + provide base class for quota series tables. For it inherits from + models.ModelBase, this is different from other tables + """ + __table_args__ = {'mysql_engine': 'InnoDB'} + + metadata = None + + def delete(self, session): + """Delete this object.""" + self.deleted = True + self.deleted_at = timeutils.utcnow() + self.save(session=session) + + +class Quotas(core.ModelBase, QuotasBase): """Represents a single quota override for a project. If there is no row for a given project id and resource, then the @@ -154,16 +175,96 @@ class Quota(core.ModelBase, core.DictBase, models.TimestampMixin): """ __tablename__ = 'quotas' __table_args__ = ( - schema.UniqueConstraint('project_id', 'resource', - name='uniq_quotas0project_id0resource'), - ) - attributes = ['id', 'project_id', 'resource', 'hard_limit', - 'created_at', 'updated_at'] + schema.UniqueConstraint( + 'project_id', 'resource', 'deleted', + name='uniq_quotas0project_id0resource0deleted'),) + attributes = ['id', 'project_id', 'resource', + 'hard_limit', 'allocated', + 'created_at', 'updated_at', 'deleted_at', 'deleted'] id = sql.Column(sql.Integer, primary_key=True) - project_id = sql.Column(sql.String(255)) + project_id = sql.Column(sql.String(255), index=True) resource = sql.Column(sql.String(255), nullable=False) hard_limit = sql.Column(sql.Integer) + allocated = sql.Column(sql.Integer, default=0) + + +class QuotaClasses(core.ModelBase, QuotasBase): + """Quota_classes. + + make default quota as a update-able quota.Mainly for the command + quota-class-show and quota-class-update + """ + __tablename__ = 'quota_classes' + __table_args__ = ( + schema.UniqueConstraint( + 'class_name', 'resource', 'deleted', + name='uniq_quota_classes0class_name0resource0deleted'), + ) + attributes = ['id', 'class_name', 'resource', 'hard_limit', + 'created_at', 'updated_at', 'deleted_at', 'deleted'] + + id = sql.Column(sql.Integer, primary_key=True) + class_name = sql.Column(sql.String(255), index=True) + resource = sql.Column(sql.String(255), nullable=False) + hard_limit = sql.Column(sql.Integer) + + +class QuotaUsages(core.ModelBase, QuotasBase): + """Quota_uages. + + store quota usages for project resource + """ + __tablename__ = 'quota_usages' + __table_args__ = () + attributes = ['id', 'project_id', 'user_id', 'resource', + 'in_use', 'reserved', 'until_refresh', + 'created_at', 'updated_at', 'deleted_at', 'deleted'] + + id = sql.Column(sql.Integer, primary_key=True) + project_id = sql.Column(sql.String(255), index=True) + user_id = sql.Column(sql.String(255), index=True) + resource = sql.Column(sql.String(255), nullable=False) + + in_use = sql.Column(sql.Integer) + reserved = sql.Column(sql.Integer, default=0) + + until_refresh = sql.Column(sql.Integer, default=0) + + @property + def total(self): + return self.in_use + self.reserved + + +class Reservation(core.ModelBase, QuotasBase): + """Reservation. + + Represents a resource reservation for quotas + """ + __tablename__ = 'reservations' + __table_args__ = () + attributes = ['id', 'uuid', 'usage_id', 'project_id', 'resource', + 'delta', 'expire', + 'created_at', 'updated_at', 'deleted_at', 'deleted'] + + id = sql.Column(sql.Integer, primary_key=True) + uuid = sql.Column(sql.String(36), nullable=False) + + usage_id = sql.Column(sql.Integer, + sql.ForeignKey('quota_usages.id'), + nullable=False) + + project_id = sql.Column(sql.String(255), index=True) + resource = sql.Column(sql.String(255)) + + delta = sql.Column(sql.Integer) + expire = sql.Column(sql.DateTime, nullable=False) + + usage = relationship( + "QuotaUsages", + foreign_keys=usage_id, + primaryjoin='and_(Reservation.usage_id == QuotaUsages.id,' + 'QuotaUsages.deleted == 0)') class VolumeTypes(core.ModelBase, core.DictBase, models.TimestampMixin): diff --git a/tricircle/tests/unit/db/test_api.py b/tricircle/tests/unit/db/test_api.py index 1188f93..988b4e1 100644 --- a/tricircle/tests/unit/db/test_api.py +++ b/tricircle/tests/unit/db/test_api.py @@ -13,10 +13,14 @@ # License for the specific language governing permissions and limitations # under the License. - +import datetime +import six import unittest from tricircle.common import context +from tricircle.common import exceptions +from tricircle.common import quota + from tricircle.db import api from tricircle.db import core from tricircle.db import models @@ -181,3 +185,323 @@ class APITest(unittest.TestCase): def tearDown(self): core.ModelBase.metadata.drop_all(core.get_engine()) + + +class QuotaApiTestCase(unittest.TestCase): + def setUp(self): + core.initialize() + core.ModelBase.metadata.create_all(core.get_engine()) + self.context = context.get_admin_context() + + def _quota_reserve(self, context, project_id): + """Create sample Quota, QuotaUsage and Reservation objects. + + There is no method api.quota_usage_create(), so we have to use + api.quota_reserve() for creating QuotaUsage objects. + + Returns reservations uuids. + + """ + quotas = {} + resources = {} + deltas = {} + for i, resource in enumerate(('volumes', 'gigabytes')): + quota_obj = api.quota_create(context, project_id, resource, i + 1) + quotas[resource] = quota_obj.hard_limit + resources[resource] = quota.ReservableResource(resource, None) + deltas[resource] = i + 1 + return api.quota_reserve( + context, resources, quotas, deltas, + datetime.datetime.utcnow(), datetime.datetime.utcnow(), + datetime.timedelta(days=1), project_id + ) + + def _dict_from_object(self, obj, ignored_keys): + if ignored_keys is None: + ignored_keys = [] + if isinstance(obj, dict): + items = obj.items() + else: + items = obj.iteritems() + return {k: v for k, v in items + if k not in ignored_keys} + + def _assertEqualObjects(self, obj1, obj2, ignored_keys=None): + obj1 = self._dict_from_object(obj1, ignored_keys) + obj2 = self._dict_from_object(obj2, ignored_keys) + + self.assertEqual( + len(obj1), len(obj2), + "Keys mismatch: %s" % six.text_type( + set(obj1.keys()) ^ set(obj2.keys()))) + for key, value in obj1.items(): + self.assertEqual(value, obj2[key]) + + def tearDown(self): + core.ModelBase.metadata.drop_all(core.get_engine()) + + +class DBAPIReservationTestCase(QuotaApiTestCase): + + """Tests for db.api.reservation_* methods.""" + + def setUp(self): + super(DBAPIReservationTestCase, self).setUp() + self.values = { + 'uuid': 'sample-uuid', + 'project_id': 'project1', + 'resource': 'resource', + 'delta': 42, + 'expire': (datetime.datetime.utcnow() + + datetime.timedelta(days=1)), + 'usage': {'id': 1} + } + + def test_reservation_commit(self): + reservations = self._quota_reserve(self.context, 'project1') + expected = {'project_id': 'project1', + 'volumes': {'reserved': 1, 'in_use': 0}, + 'gigabytes': {'reserved': 2, 'in_use': 0}, + } + self.assertEqual(expected, + api.quota_usage_get_all_by_project( + self.context, 'project1')) + api.reservation_commit(self.context, reservations, 'project1') + expected = {'project_id': 'project1', + 'volumes': {'reserved': 0, 'in_use': 1}, + 'gigabytes': {'reserved': 0, 'in_use': 2}, + } + self.assertEqual(expected, + api.quota_usage_get_all_by_project( + self.context, + 'project1')) + + def test_reservation_rollback(self): + reservations = self._quota_reserve(self.context, 'project1') + expected = {'project_id': 'project1', + 'volumes': {'reserved': 1, 'in_use': 0}, + 'gigabytes': {'reserved': 2, 'in_use': 0}, + } + self.assertEqual(expected, + api.quota_usage_get_all_by_project( + self.context, + 'project1')) + api.reservation_rollback(self.context, reservations, 'project1') + expected = {'project_id': 'project1', + 'volumes': {'reserved': 0, 'in_use': 0}, + 'gigabytes': {'reserved': 0, 'in_use': 0}, + } + self.assertEqual(expected, + api.quota_usage_get_all_by_project( + self.context, + 'project1')) + + def test_reservation_expire(self): + self.values['expire'] = datetime.datetime.utcnow() + \ + datetime.timedelta(days=1) + self._quota_reserve(self.context, 'project1') + api.reservation_expire(self.context) + + expected = {'project_id': 'project1', + 'gigabytes': {'reserved': 0, 'in_use': 0}, + 'volumes': {'reserved': 0, 'in_use': 0}} + self.assertEqual(expected, + api.quota_usage_get_all_by_project( + self.context, + 'project1')) + + +class DBAPIQuotaClassTestCase(QuotaApiTestCase): + + """Tests for api.api.quota_class_* methods.""" + + def setUp(self): + super(DBAPIQuotaClassTestCase, self).setUp() + self.sample_qc = api.quota_class_create(self.context, 'test_qc', + 'test_resource', 42) + + def test_quota_class_get(self): + qc = api.quota_class_get(self.context, 'test_qc', 'test_resource') + self._assertEqualObjects(self.sample_qc, qc) + + def test_quota_class_destroy(self): + api.quota_class_destroy(self.context, 'test_qc', 'test_resource') + self.assertRaises(exceptions.QuotaClassNotFound, + api.quota_class_get, self.context, + 'test_qc', 'test_resource') + + def test_quota_class_get_not_found(self): + self.assertRaises(exceptions.QuotaClassNotFound, + api.quota_class_get, self.context, 'nonexistent', + 'nonexistent') + + def test_quota_class_get_all_by_name(self): + api.quota_class_create(self.context, 'test2', 'res1', 43) + api.quota_class_create(self.context, 'test2', 'res2', 44) + self.assertEqual({'class_name': 'test_qc', 'test_resource': 42}, + api.quota_class_get_all_by_name(self.context, + 'test_qc')) + self.assertEqual({'class_name': 'test2', 'res1': 43, 'res2': 44}, + api.quota_class_get_all_by_name(self.context, + 'test2')) + + def test_quota_class_update(self): + api.quota_class_update(self.context, 'test_qc', 'test_resource', 43) + updated = api.quota_class_get(self.context, 'test_qc', + 'test_resource') + self.assertEqual(43, updated['hard_limit']) + + def test_quota_class_destroy_all_by_name(self): + api.quota_class_create(self.context, 'test2', 'res1', 43) + api.quota_class_create(self.context, 'test2', 'res2', 44) + api.quota_class_destroy_all_by_name(self.context, 'test2') + self.assertEqual({'class_name': 'test2'}, + api.quota_class_get_all_by_name(self.context, + 'test2')) + + +class DBAPIQuotaTestCase(QuotaApiTestCase): + + """Tests for api.api.reservation_* methods.""" + + def test_quota_create(self): + _quota = api.quota_create(self.context, 'project1', 'resource', 99) + self.assertEqual('resource', _quota.resource) + self.assertEqual(99, _quota.hard_limit) + self.assertEqual('project1', _quota.project_id) + + def test_quota_get(self): + _quota = api.quota_create(self.context, 'project1', 'resource', 99) + quota_db = api.quota_get(self.context, 'project1', 'resource') + self._assertEqualObjects(_quota, quota_db) + + def test_quota_get_all_by_project(self): + for i in range(3): + for j in range(3): + api.quota_create(self.context, 'proj%d' % i, 'res%d' % j, j) + for i in range(3): + quotas_db = api.quota_get_all_by_project(self.context, + 'proj%d' % i) + self.assertEqual({'project_id': 'proj%d' % i, + 'res0': 0, + 'res1': 1, + 'res2': 2}, quotas_db) + + def test_quota_update(self): + api.quota_create(self.context, 'project1', 'resource1', 41) + api.quota_update(self.context, 'project1', 'resource1', 42) + _quota = api.quota_get(self.context, 'project1', 'resource1') + self.assertEqual(42, _quota.hard_limit) + self.assertEqual('resource1', _quota.resource) + self.assertEqual('project1', _quota.project_id) + + def test_quota_update_nonexistent(self): + self.assertRaises(exceptions.ProjectQuotaNotFound, + api.quota_update, + self.context, + 'project1', + 'resource1', + 42) + + def test_quota_get_nonexistent(self): + self.assertRaises(exceptions.ProjectQuotaNotFound, + api.quota_get, + self.context, + 'project1', + 'resource1') + + def test_quota_reserve(self): + reservations = self._quota_reserve(self.context, 'project1') + self.assertEqual(2, len(reservations)) + quota_usage = api.quota_usage_get_all_by_project(self.context, + 'project1') + self.assertEqual({'project_id': 'project1', + 'gigabytes': {'reserved': 2, 'in_use': 0}, + 'volumes': {'reserved': 1, 'in_use': 0}}, + quota_usage) + + def test_quota_destroy(self): + api.quota_create(self.context, 'project1', 'resource1', 41) + self.assertIsNone(api.quota_destroy(self.context, 'project1', + 'resource1')) + self.assertRaises(exceptions.ProjectQuotaNotFound, api.quota_get, + self.context, 'project1', 'resource1') + + def test_quota_destroy_by_project(self): + # Create limits, reservations and usage for project + project = 'project1' + self._quota_reserve(self.context, project) + expected_usage = {'project_id': project, + 'volumes': {'reserved': 1, 'in_use': 0}, + 'gigabytes': {'reserved': 2, 'in_use': 0}} + expected = {'project_id': project, 'gigabytes': 2, 'volumes': 1} + + # Check that quotas are there + self.assertEqual(expected, + api.quota_get_all_by_project(self.context, project)) + self.assertEqual(expected_usage, + api.quota_usage_get_all_by_project(self.context, + project)) + + # Destroy only the limits + api.quota_destroy_by_project(self.context, project) + + # Confirm that limits have been removed + self.assertEqual({'project_id': project}, + api.quota_get_all_by_project(self.context, project)) + + # But that usage and reservations are the same + self.assertEqual(expected_usage, + api.quota_usage_get_all_by_project(self.context, + project)) + + def test_quota_destroy_sqlalchemy_all_by_project_(self): + # Create limits, reservations and usage for project + project = 'project1' + self._quota_reserve(self.context, project) + expected_usage = {'project_id': project, + 'volumes': {'reserved': 1, 'in_use': 0}, + 'gigabytes': {'reserved': 2, 'in_use': 0}} + expected = {'project_id': project, 'gigabytes': 2, 'volumes': 1} + expected_result = {'project_id': project} + + # Check that quotas are there + self.assertEqual(expected, + api.quota_get_all_by_project(self.context, project)) + self.assertEqual(expected_usage, + api.quota_usage_get_all_by_project(self.context, + project)) + + # Destroy all quotas using SQLAlchemy Implementation + api.quota_destroy_all_by_project(self.context, project, + only_quotas=False) + + # Check that all quotas have been deleted + self.assertEqual(expected_result, + api.quota_get_all_by_project(self.context, project)) + self.assertEqual(expected_result, + api.quota_usage_get_all_by_project(self.context, + project)) + + def test_quota_usage_get_nonexistent(self): + self.assertRaises(exceptions.QuotaUsageNotFound, + api.quota_usage_get, + self.context, + 'p1', + 'nonexitent_resource') + + def test_quota_usage_get(self): + self._quota_reserve(self.context, 'p1') + quota_usage = api.quota_usage_get(self.context, 'p1', 'gigabytes') + expected = {'resource': 'gigabytes', 'project_id': 'p1', + 'in_use': 0, 'reserved': 2, 'total': 2} + for key, value in expected.items(): + self.assertEqual(value, quota_usage[key], key) + + def test_quota_usage_get_all_by_project(self): + self._quota_reserve(self.context, 'p1') + expected = {'project_id': 'p1', + 'volumes': {'in_use': 0, 'reserved': 1}, + 'gigabytes': {'in_use': 0, 'reserved': 2}} + self.assertEqual(expected, api.quota_usage_get_all_by_project( + self.context, 'p1')) diff --git a/tricircle/tests/unit/db/test_models.py b/tricircle/tests/unit/db/test_models.py index b6d08ae..47ad566 100644 --- a/tricircle/tests/unit/db/test_models.py +++ b/tricircle/tests/unit/db/test_models.py @@ -14,6 +14,7 @@ # under the License. +import datetime import inspect import unittest @@ -47,6 +48,8 @@ def _get_field_value(column): return 1.0 elif isinstance(column.type, sql.Boolean): return True + elif isinstance(column.type, sql.DateTime): + return datetime.datetime.utcnow() else: return None @@ -222,8 +225,9 @@ class ModelsTest(unittest.TestCase): with self.context.session.begin(): core.create_resource( self.context, model_class, create_dict) - except Exception: - self.fail('test_resources raised Exception unexpectedly') + except Exception as e: + msg = str(e) + self.fail('test_resources raised Exception unexpectedly %s' % msg) def test_resource_routing_unique_key(self): pod = {'pod_id': 'test_pod1_uuid',