From 558e3d5ad70544d9d6d7d7e35a4907411104431d Mon Sep 17 00:00:00 2001 From: XiongQiu Date: Wed, 11 May 2016 19:19:53 +0800 Subject: [PATCH] Implement volume type basic operation The purpose of this patch set is to provide the end users with the following functionalities: 1. Create volume type resources, only admin is able to create volume type 2. Show details for a volume type by id 3. List volume types 4. Update a volume type, only admin is able to update volume type 5. Delete a volume type, only admin is able to delete volume type Change-Id: I6e3188018eff6db155ec02dbd2af6aefc0363df9 --- tricircle/cinder_apigw/controllers/root.py | 2 + .../cinder_apigw/controllers/volume_type.py | 286 +++++++++++++++ tricircle/common/context.py | 32 +- tricircle/common/exceptions.py | 26 ++ tricircle/common/utils.py | 27 ++ tricircle/db/api.py | 331 ++++++++++++++++++ .../db/migrate_repo/versions/002_resource.py | 43 ++- tricircle/db/models.py | 71 +++- tricircle/tests/unit/cinder_apigw/__init__.py | 0 .../unit/cinder_apigw/controllers/__init__.py | 0 .../cinder_apigw/controllers/test_volume.py | 271 ++++++++++++++ 11 files changed, 1081 insertions(+), 8 deletions(-) create mode 100644 tricircle/cinder_apigw/controllers/volume_type.py create mode 100644 tricircle/tests/unit/cinder_apigw/__init__.py create mode 100644 tricircle/tests/unit/cinder_apigw/controllers/__init__.py create mode 100644 tricircle/tests/unit/cinder_apigw/controllers/test_volume.py diff --git a/tricircle/cinder_apigw/controllers/root.py b/tricircle/cinder_apigw/controllers/root.py index 33eba87..20d39ab 100644 --- a/tricircle/cinder_apigw/controllers/root.py +++ b/tricircle/cinder_apigw/controllers/root.py @@ -18,6 +18,7 @@ import pecan import oslo_log.log as logging from tricircle.cinder_apigw.controllers import volume +from tricircle.cinder_apigw.controllers import volume_type LOG = logging.getLogger(__name__) @@ -65,6 +66,7 @@ class V2Controller(object): self.resource_controller = { 'volumes': volume.VolumeController, + 'types': volume_type.VolumeTypeController } @pecan.expose() diff --git a/tricircle/cinder_apigw/controllers/volume_type.py b/tricircle/cinder_apigw/controllers/volume_type.py new file mode 100644 index 0000000..2068216 --- /dev/null +++ b/tricircle/cinder_apigw/controllers/volume_type.py @@ -0,0 +1,286 @@ +# Copyright 2016 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import pecan +from pecan import expose +from pecan import rest + +from oslo_log import log as logging +from oslo_utils import uuidutils + +import tricircle.common.context as t_context +from tricircle.common import exceptions +from tricircle.common.i18n import _ +from tricircle.common.i18n import _LE +from tricircle.common import utils +import tricircle.db.api as db_api +from tricircle.db import core +from tricircle.db import models + +LOG = logging.getLogger(__name__) + + +class VolumeTypeController(rest.RestController): + + def __init__(self, tenant_id): + self.tenant_id = tenant_id + + def _metadata_refs(self, metadata_dict, meta_class): + metadata_refs = [] + + if metadata_dict: + for k, v in metadata_dict.items(): + metadata_ref = meta_class() + metadata_ref['key'] = k + metadata_ref['value'] = v + metadata_refs.append(metadata_ref) + return metadata_refs + + @expose(generic=True, template='json') + def post(self, **kw): + """Creates volume types.""" + context = t_context.extract_context_from_environ() + + if not context.is_admin: + return utils.format_cinder_error( + 403, _("Policy doesn't allow volume_extension:types_manage " + "to be performed.")) + + if 'volume_type' not in kw: + return utils.format_cinder_error( + 400, _("Missing required element 'volume_type' in " + "request body.")) + + projects = [] + + if self.tenant_id is not None: + projects = [self.tenant_id] + + vol_type = kw['volume_type'] + name = vol_type.get('name', None) + description = vol_type.get('description') + specs = vol_type.get('extra_specs', {}) + is_public = vol_type.pop('os-volume-type-access:is_public', True) + + if name is None or len(name.strip()) == 0: + return utils.format_cinder_error( + 400, _("Volume type name can not be empty.")) + + try: + utils.check_string_length(name, 'Type name', + min_len=1, max_len=255) + except exceptions.InvalidInput as e: + return utils.format_cinder_error( + 400, e.message) + + if description is not None: + try: + utils.check_string_length(description, 'Type description', + min_len=0, max_len=255) + except exceptions.InvalidInput as e: + return utils.format_cinder_error(400, e.message) + + if not utils.is_valid_boolstr(is_public): + msg = _("Invalid value '%(is_public)s' for is_public. " + "Accepted values: True or False.") % { + 'is_public': is_public} + return utils.format_cinder_error(400, msg) + + vol_type['extra_specs'] = specs + vol_type['is_public'] = is_public + vol_type['id'] = uuidutils.generate_uuid() + + session = core.get_session() + with session.begin(): + try: + db_api.volume_type_get_by_name(context, vol_type['name'], + session) + return utils.format_cinder_error( + 409, _("Volume Type %(id)s already exists.") % { + 'id': vol_type['id']}) + except exceptions.VolumeTypeNotFoundByName: + pass + try: + extra_specs = vol_type['extra_specs'] + vol_type['extra_specs'] = \ + self._metadata_refs(vol_type.get('extra_specs'), + models.VolumeTypeExtraSpecs) + volume_type_ref = models.VolumeTypes() + volume_type_ref.update(vol_type) + session.add(volume_type_ref) + for project in set(projects): + access_ref = models.VolumeTypeProjects() + access_ref.update({"volume_type_id": volume_type_ref.id, + "project_id": project}) + access_ref.save(session=session) + except Exception as e: + LOG.exception(_LE('Fail to create volume type: %(name)s,' + '%(exception)s'), + {'name': vol_type['name'], + 'exception': e}) + return utils.format_cinder_error( + 500, _('Fail to create volume type')) + + vol_type['extra_specs'] = extra_specs + return {'volume_type': vol_type} + + @expose(generic=True, template='json') + def get_one(self, _id): + """Retrieves single volume type by id. + + :param _id: id of volume type to be retrieved + :returns: retrieved volume type + """ + context = t_context.extract_context_from_environ() + try: + result = db_api.volume_type_get(context, _id) + except exceptions.VolumeTypeNotFound as e: + return utils.format_cinder_error(404, e.message) + except Exception as e: + LOG.exception(_LE('Volume type not found: %(id)s,' + '%(exception)s'), + {'id': _id, + 'exception': e}) + return utils.format_cinder_error( + 404, _("Volume type %(id)s could not be found.") % { + 'id': _id}) + return {'volume_type': result} + + @expose(generic=True, template='json') + def get_all(self): + """Get all non-deleted volume_types.""" + filters = {} + context = t_context.extract_context_from_environ() + if not context.is_admin: + # Only admin has query access to all volume types + filters['is_public'] = True + try: + list_result = db_api.volume_type_get_all(context, + list_result=True, + filters=filters) + except Exception as e: + LOG.exception(_LE('Fail to retrieve volume types: %(exception)s'), + {'exception': e}) + return utils.format_cinder_error(500, e) + + return {'volume_types': list_result} + + @expose(generic=True, template='json') + def put(self, _id, **kw): + """Update volume type by id. + + :param _id: id of volume type to be updated + :param kw: dictionary of values to be updated + :returns: updated volume type + """ + context = t_context.extract_context_from_environ() + + if not context.is_admin: + return utils.format_cinder_error( + 403, _("Policy doesn't allow volume_extension:types_manage " + "to be performed.")) + + if 'volume_type' not in kw: + return utils.format_cinder_error( + 400, _("Missing required element 'volume_type' in " + "request body.")) + + values = kw['volume_type'] + name = values.get('name') + description = values.get('description') + is_public = values.get('os-volume-type-access:is_public') + + # Name and description can not be both None. + # If name specified, name can not be empty. + if name and len(name.strip()) == 0: + return utils.format_cinder_error( + 400, _("Volume type name can not be empty.")) + + if name is None and description is None and is_public is None: + msg = _("Specify volume type name, description, is_public or " + "a combination thereof.") + return utils.format_cinder_error(400, msg) + + if is_public is not None and not utils.is_valid_boolstr(is_public): + msg = _("Invalid value '%(is_public)s' for is_public. Accepted " + "values: True or False.") % {'is_public': is_public} + return utils.format_cinder_error(400, msg) + + if name: + try: + utils.check_string_length(name, 'Type name', + min_len=1, max_len=255) + except exceptions.InvalidInput as e: + return utils.format_cinder_error(400, e.message) + + if description is not None: + try: + utils.check_string_length(description, 'Type description', + min_len=0, max_len=255) + except exceptions.InvalidInput as e: + return utils.format_cinder_error(400, e.message) + + try: + type_updated = \ + db_api.volume_type_update(context, _id, + dict(name=name, + description=description, + is_public=is_public)) + except exceptions.VolumeTypeNotFound as e: + return utils.format_cinder_error(404, e.message) + except exceptions.VolumeTypeExists as e: + return utils.format_cinder_error(409, e.message) + except exceptions.VolumeTypeUpdateFailed as e: + return utils.format_cinder_error(500, e.message) + except Exception as e: + LOG.exception(_LE('Fail to update volume type: %(name)s,' + '%(exception)s'), + {'name': values['name'], + 'exception': e}) + return utils.format_cinder_error( + 500, _("Fail to update volume type.")) + return {'volume_type': type_updated} + + @expose(generic=True, template='json') + def delete(self, _id): + """Marks volume types as deleted. + + :param _id: id of volume type to be deleted + """ + context = t_context.extract_context_from_environ() + + if not context.is_admin: + return utils.format_cinder_error( + 403, _("Policy doesn't allow volume_extension:types_manage " + "to be performed.")) + + session = core.get_session() + with session.begin(): + try: + db_api.volume_type_get(context, _id, session) + except exceptions.VolumeTypeNotFound as e: + return utils.format_cinder_error(404, e.message) + try: + db_api.volume_type_delete(context, _id, session) + except Exception as e: + LOG.exception(_LE('Fail to update volume type: %(id)s,' + '%(exception)s'), + {'id': _id, + 'exception': e}) + return utils.format_cinder_error( + 500, _('Fail to delete volume type.')) + + pecan.response.status = 202 + return pecan.response diff --git a/tricircle/common/context.py b/tricircle/common/context.py index c08a16a..f3d81ea 100644 --- a/tricircle/common/context.py +++ b/tricircle/common/context.py @@ -19,6 +19,7 @@ from pecan import request import oslo_context.context as oslo_ctx +from tricircle.common.i18n import _ from tricircle.db import core @@ -70,9 +71,18 @@ def get_context_from_neutron_context(context): class ContextBase(oslo_ctx.RequestContext): def __init__(self, auth_token=None, user_id=None, tenant_id=None, - is_admin=False, request_id=None, overwrite=True, - user_name=None, tenant_name=None, quota_class=None, - **kwargs): + is_admin=False, read_deleted="no", request_id=None, + overwrite=True, user_name=None, tenant_name=None, + quota_class=None, **kwargs): + """Initialize RequestContext. + + :param read_deleted: 'no' indicates deleted records are hidden, 'yes' + indicates deleted records are visible, 'only' indicates that + *only* deleted records are visible. + + :param overwrite: Set to False to ensure that the greenthread local + copy of the index is not overwritten. + """ super(ContextBase, self).__init__( auth_token=auth_token, user=user_id or kwargs.get('user', None), @@ -89,6 +99,22 @@ class ContextBase(oslo_ctx.RequestContext): self.user_name = user_name self.tenant_name = tenant_name self.quota_class = quota_class + self.read_deleted = read_deleted + + def _get_read_deleted(self): + return self._read_deleted + + def _set_read_deleted(self, read_deleted): + if read_deleted not in ('no', 'yes', 'only'): + raise ValueError(_("read_deleted can only be one of 'no', " + "'yes' or 'only', not %r") % read_deleted) + self._read_deleted = read_deleted + + def _del_read_deleted(self): + del self._read_deleted + + read_deleted = property(_get_read_deleted, _set_read_deleted, + _del_read_deleted) def to_dict(self): ctx_dict = super(ContextBase, self).to_dict() diff --git a/tricircle/common/exceptions.py b/tricircle/common/exceptions.py index 7357631..3055910 100644 --- a/tricircle/common/exceptions.py +++ b/tricircle/common/exceptions.py @@ -273,3 +273,29 @@ class ValidationError(TricircleException): class HTTPForbiddenError(TricircleException): message = _("%(msg)s") code = 403 + + +class VolumeTypeNotFound(NotFound): + message = _("Volume type %(volume_type_id)s could not be found.") + + +class VolumeTypeNotFoundByName(VolumeTypeNotFound): + message = _("Volume type with name %(volume_type_name)s " + "could not be found.") + + +class VolumeTypeExtraSpecsNotFound(NotFound): + message = _("Volume Type %(volume_type_id)s has no extra specs with " + "key %(extra_specs_key)s.") + + +class Duplicate(TricircleException): + pass + + +class VolumeTypeExists(Duplicate): + message = _("Volume Type %(id)s already exists.") + + +class VolumeTypeUpdateFailed(TricircleException): + message = _("Cannot update volume_type %(id)s") diff --git a/tricircle/common/utils.py b/tricircle/common/utils.py index 0ed7576..c99cb1d 100644 --- a/tricircle/common/utils.py +++ b/tricircle/common/utils.py @@ -15,6 +15,8 @@ import six +import pecan + import tricircle.common.exceptions as t_exceptions from tricircle.common.i18n import _ @@ -116,3 +118,28 @@ def check_string_length(value, name=None, min_len=0, max_len=None): def get_bottom_network_name(network): return '%s#%s' % (network['id'], network['name']) + + +def format_error(code, message, error_type=None): + error_type_map = {400: 'badRequest', + 403: 'forbidden', + 404: 'itemNotFound', + 409: 'conflictingRequest', + 500: 'internalServerError'} + pecan.response.status = code + if not error_type: + if code in error_type_map: + error_type = error_type_map[code] + else: + error_type = 'Error' + # format error message in this form so nova client can + # correctly parse it + return {error_type: {'message': message, 'code': code}} + + +def format_nova_error(code, message, error_type=None): + return format_error(code, message, error_type) + + +def format_cinder_error(code, message, error_type=None): + return format_error(code, message, error_type) diff --git a/tricircle/db/api.py b/tricircle/db/api.py index 27e4ccd..c1e340c 100644 --- a/tricircle/db/api.py +++ b/tricircle/db/api.py @@ -23,6 +23,9 @@ from oslo_db import exception as db_exc from oslo_log import log as logging from oslo_utils import timeutils from oslo_utils import uuidutils +from sqlalchemy import or_, and_ +from sqlalchemy.orm import joinedload +from sqlalchemy.sql.expression import literal_column from tricircle.common import constants from tricircle.common.context import is_admin_context as _is_admin_context @@ -380,6 +383,21 @@ def _retry_on_deadlock(f): return wrapped +def handle_db_data_error(f): + def wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except db_exc.DBDataError: + msg = _('Error writing field to database') + LOG.exception(msg) + raise exceptions.Invalid(msg) + except Exception as e: + LOG.exception(str(e)) + raise + + return wrapper + + def model_query(context, *args, **kwargs): """Query helper that accounts for context's `read_deleted` field. @@ -904,3 +922,316 @@ def reservation_expire(context): reservation.usage.save(session=context.session) reservation.delete(session=context.session) + + +def _dict_with_extra_specs_if_authorized(context, inst_type_query): + """Convert type query result to dict with extra_spec and rate_limit. + + Takes a volume type query returned by sqlalchemy and returns it + as a dictionary, converting the extra_specs entry from a list + of dicts. + + NOTE: + the contents of extra-specs are admin readable only. + If the context passed in for this request is not about admin, + we will return an empty extra-specs dict rather than + providing extra-specs details. + + :param context: The request context, for access checks. + :param inst_type_query: list of extra-specs. + :returns dictionary of extra-specs. + + Example of response of admin context: + + 'extra_specs' : [{'key': 'k1', 'value': 'v1', ...}, ...] + to a single dict: + 'extra_specs' : {'k1': 'v1'} + + """ + + inst_type_dict = dict(inst_type_query) + if not context.is_admin: + del (inst_type_dict['extra_specs']) + else: + extra_specs = {x['key']: x['value'] + for x in inst_type_query['extra_specs']} + inst_type_dict['extra_specs'] = extra_specs + return inst_type_dict + + +@require_context +def _volume_type_get_by_name(context, name, session=None): + result = model_query(context, models.VolumeTypes, session=session). \ + options(joinedload('extra_specs')). \ + filter_by(name=name). \ + first() + + if not result: + raise exceptions.VolumeTypeNotFoundByName(volume_type_name=name) + + return _dict_with_extra_specs_if_authorized(context, result) + + +@require_context +def volume_type_get_by_name(context, name, session=None): + """Return a dict describing specific volume_type. + + :param context: The request context, for access checks. + :param name: The name of volume type to be found. + :returns Volume type. + """ + return _volume_type_get_by_name(context, name, session) + + +def _volume_type_get_query(context, session=None, read_deleted='no'): + query = model_query(context, models.VolumeTypes, + session=session, + read_deleted=read_deleted). \ + options(joinedload('extra_specs')) + + if not context.is_admin: + is_public = True + the_filter = [models.VolumeTypes.is_public == is_public] + query.filter(or_(*the_filter)) + + return query + + +def _volume_type_get_db_object(context, id, session=None, inactive=False): + read_deleted = "yes" if inactive else "no" + result = _volume_type_get_query( + context, session, read_deleted). \ + filter_by(id=id). \ + first() + return result + + +@require_context +def _volume_type_get(context, id, session=None, inactive=False): + result = _volume_type_get_db_object(context, id, session, inactive) + + if not result: + raise exceptions.VolumeTypeNotFound(volume_type_id=id) + + vtype = _dict_with_extra_specs_if_authorized(context, result) + + return vtype + + +@require_context +def volume_type_get(context, id, inactive=False): + """Return a dict describing specific volume_type. + + :param context: The request context, for access checks. + :param id: The id of volume type to be found. + :returns Volume type. + """ + + return _volume_type_get(context, id, + session=None, + inactive=inactive) + + +@require_context +def volume_type_delete(context, id, session): + """delete a volume_type by id. + + :param context: The request context, for access checks. + :param id: The id of volume type to be deleted. + """ + model_query(context, models.VolumeTypes, session=session, read_deleted="no").\ + filter_by(id=id). \ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + model_query(context, models.VolumeTypeExtraSpecs, session=session, read_deleted="no"). \ + filter_by(volume_type_id=id). \ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + +def is_valid_model_filters(model, filters): + """Return True if filter values exist on the model + + :param model: a Cinder model + :param filters: dictionary of filters + """ + for key in filters.keys(): + if not hasattr(model, key): + return False + return True + + +def _process_volume_types_filters(query, filters): + context = filters.pop('context', None) + + if filters.get('is_public'): + the_filter = [models.VolumeTypes.is_public == filters['is_public']] + + if filters['is_public'] and context.project_id is not None: + projects_attr = getattr(models.VolumeTypes, 'projects') + the_filter.append( + [projects_attr.any(project_id=context.project_id, + deleted=0)]) + + if len(the_filter) > 1: + query = query.filter(or_(*the_filter)) + else: + query = query.filter(the_filter[0]) + + if 'is_public' in filters: + del filters['is_public'] + + if filters: + # Ensure that filters' keys exist on the model + if not is_valid_model_filters(models.VolumeTypes, filters): + return + + if filters.get('extra_specs') is not None: + the_filter = [] + searchdict = filters.get('extra_specs') + extra_specs = getattr(models.VolumeTypes, 'extra_specs') + for k, v in searchdict.items(): + the_filter.append([extra_specs.any(key=k, value=v, + deleted=False)]) + + if len(the_filter) > 1: + query = query.filter(and_(*the_filter)) + else: + query = query.filter(the_filter[0]) + del filters['extra_specs'] + query = query.filter_by(**filters) + return query + + +@require_context +def volume_type_get_all(context, inactive=False, filters=None, + list_result=False): + """Returns a dict describing all volume_types with name as key. + + :param context: context to query under + :param inactive: Pass true as argument if you want deleted volume types + returned also. + :param filters: dictionary of filters; values that are in lists, tuples, + or sets cause an 'IN' operation, while exact matching + is used for other values, see _process_volume_type_filters + function for more information + :param list_result: For compatibility, if list_result = True, return + a list instead of dict. + :returns: list/dict of matching volume types + """ + read_deleted = 'yes' if inactive else 'no' + session = core.get_session() + with session.begin(): + filters = filters or {} + filters['context'] = context + # Generate the query + query = _volume_type_get_query(context, session=session, + read_deleted=read_deleted) + query = _process_volume_types_filters(query, filters) + + # No volume types would match, return empty dict or list + if query is None: + if list_result: + return [] + return {} + + rows = query.all() + + if list_result: + result = [_dict_with_extra_specs_if_authorized(context, row) + for row in rows] + return result + result = {row['name']: _dict_with_extra_specs_if_authorized(context, + row) + for row in rows} + return result + + +@require_context +def _volume_type_ref_get(context, id, session=None, inactive=False): + read_deleted = "yes" if inactive else "no" + result = model_query(context, + models.VolumeTypes, + session=session, + read_deleted=read_deleted).\ + options(joinedload('extra_specs')).\ + filter_by(id=id).\ + first() + + if not result: + raise exceptions.VolumeTypeNotFound(volume_type_id=id) + + return result + + +@handle_db_data_error +@require_admin_context +def volume_type_update(context, volume_type_id, values): + """Update volume type by volume_type_id. + + :param volume_type_id: id of volume type to be updated + :param values: dictionary of values to be updated + :returns: updated volume type + """ + session = core.get_session() + with session.begin(): + try: + # Check it exists + volume_type_ref = _volume_type_ref_get(context, + volume_type_id, + session) + if not volume_type_ref: + raise exceptions.VolumeTypeNotFound(type_id=volume_type_id) + + # No description change + if values['description'] is None: + del values['description'] + + # No is_public change + if values['is_public'] is None: + del values['is_public'] + + # No name change + if values['name'] is None: + del values['name'] + else: + # Volume type name is unique. If change to a name that + # belongs to a different volume_type , it should be + # prevented. + check_vol_type = None + try: + check_vol_type = \ + volume_type_get_by_name(context, + values['name'], + session=session) + except exceptions.VolumeTypeNotFoundByName: + pass + else: + if check_vol_type.get('id') != volume_type_id: + raise exceptions.VolumeTypeExists(id=values['name']) + + volume_type_ref.update(values) + volume_type_ref.save(session=session) + except Exception: + raise exceptions.VolumeTypeUpdateFailed(id=volume_type_id) + + return _dict_with_extra_specs_if_authorized(context, volume_type_ref) + + +@require_context +def volume_type_project_query(context, session=None, inactive=False, + filters=None): + """Get a query of volume type project. + + :param context: context to query under + :param inactive: Pass true as argument if you want deleted + volume type projects returned also. + :param filters: dictionary of filters. + """ + read_deleted = "yes" if inactive else "no" + filters = filters or {} + return model_query(context, models.VolumeTypeProjects, session=session, + read_deleted=read_deleted).filter_by(**filters) diff --git a/tricircle/db/migrate_repo/versions/002_resource.py b/tricircle/db/migrate_repo/versions/002_resource.py index 8f6cecf..5e28a49 100644 --- a/tricircle/db/migrate_repo/versions/002_resource.py +++ b/tricircle/db/migrate_repo/versions/002_resource.py @@ -190,6 +190,41 @@ def upgrade(migrate_engine): sql.Column('is_public', sql.Boolean, default=True), sql.Column('created_at', sql.DateTime), sql.Column('updated_at', sql.DateTime), + sql.Column('deleted_at', sql.DateTime), + sql.Column('deleted', sql.Boolean), + mysql_engine='InnoDB', + mysql_charset='utf8') + + volume_type_extra_specs = sql.Table( + 'volume_type_extra_specs', meta, + sql.Column('created_at', sql.DateTime), + sql.Column('updated_at', sql.DateTime), + sql.Column('deleted_at', sql.DateTime), + sql.Column('deleted', sql.Boolean), + sql.Column('id', sql.Integer, primary_key=True, nullable=False), + sql.Column('volume_type_id', sql.String(36), + sql.ForeignKey('volume_types.id'), + nullable=False), + sql.Column('key', sql.String(length=255)), + sql.Column('value', sql.String(length=255)), + mysql_engine='InnoDB', + mysql_charset='utf8') + + volume_type_projects = sql.Table( + 'volume_type_projects', meta, + sql.Column('id', sql.Integer, primary_key=True, nullable=False), + sql.Column('created_at', sql.DateTime), + sql.Column('updated_at', sql.DateTime), + sql.Column('deleted_at', sql.DateTime), + sql.Column('volume_type_id', sql.String(36), + sql.ForeignKey('volume_types.id'), + nullable=False), + sql.Column('project_id', sql.String(length=255)), + sql.Column('deleted', sql.Boolean(create_constraint=True, name=None)), + migrate.UniqueConstraint( + 'volume_type_id', 'project_id', 'deleted', + name='uniq_volume_type_projects0volume_type_id0project_id0deleted' + ), mysql_engine='InnoDB', mysql_charset='utf8') @@ -235,8 +270,8 @@ def upgrade(migrate_engine): tables = [aggregates, aggregate_metadata, instance_types, instance_type_projects, instance_type_extra_specs, key_pairs, quotas, quota_classes, quota_usages, reservations, - volume_types, job, - quality_of_service_specs, cascaded_pods_resource_routing] + volume_types, volume_type_extra_specs, volume_type_projects, + job, quality_of_service_specs, cascaded_pods_resource_routing] for table in tables: table.create() @@ -250,6 +285,10 @@ def upgrade(migrate_engine): 'references': [quota_usages.c.id]}, {'columns': [volume_types.c.qos_specs_id], 'references': [quality_of_service_specs.c.id]}, + {'columns': [volume_type_extra_specs.c.volume_type_id], + 'references': [volume_types.c.id]}, + {'columns': [volume_type_projects.c.volume_type_id], + 'references': [volume_types.c.id]}, {'columns': [quality_of_service_specs.c.specs_id], 'references': [quality_of_service_specs.c.id]}, {'columns': [aggregate_metadata.c.aggregate_id], diff --git a/tricircle/db/models.py b/tricircle/db/models.py index a24bf56..6a47417 100644 --- a/tricircle/db/models.py +++ b/tricircle/db/models.py @@ -260,11 +260,29 @@ class Reservation(core.ModelBase, QuotasBase): 'QuotaUsages.deleted == 0)') -class VolumeTypes(core.ModelBase, core.DictBase, models.TimestampMixin): +class VolumeTypeBase(models.ModelBase, core.DictBase, + models.TimestampMixin, models.SoftDeleteMixin): + """VolumeTypeBase. + + provide base class for volume type 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 VolumeTypes(core.ModelBase, VolumeTypeBase): """Represent possible volume_types of volumes offered.""" - __tablename__ = "volume_types" + __tablename__ = 'volume_types' attributes = ['id', 'name', 'description', 'qos_specs_id', 'is_public', - 'created_at', 'updated_at'] + 'created_at', 'updated_at', 'deleted_at', 'deleted'] id = sql.Column(sql.String(36), primary_key=True) name = sql.Column(sql.String(255), unique=True) @@ -275,6 +293,53 @@ class VolumeTypes(core.ModelBase, core.DictBase, models.TimestampMixin): is_public = sql.Column(sql.Boolean, default=True) +class VolumeTypeProjects(core.ModelBase, VolumeTypeBase): + """Represent projects associated volume_types.""" + __tablename__ = 'volume_type_projects' + __table_args__ = (schema.UniqueConstraint( + 'volume_type_id', 'project_id', 'deleted', + name="uniq_volume_type_projects0volume_type_id0project_id0deleted"), + ) + attributes = ['id', 'volume_type_id', 'project_id', 'created_at', + 'updated_at', 'deleted_at', 'deleted'] + id = sql.Column(sql.Integer, primary_key=True) + volume_type_id = sql.Column(sql.String(36), + sql.ForeignKey('volume_types.id'), + nullable=False) + project_id = sql.Column(sql.String(255)) + deleted = sql.Column(sql.Boolean, default=False) + + volume_type = relationship( + VolumeTypes, + backref="projects", + foreign_keys=volume_type_id, + primaryjoin='and_(' + 'VolumeTypeProjects.volume_type_id == VolumeTypes.id,' + 'VolumeTypeProjects.deleted == False)') + + +class VolumeTypeExtraSpecs(core.ModelBase, VolumeTypeBase): + """Represents additional specs as key/value pairs for a volume_type.""" + __tablename__ = 'volume_type_extra_specs' + attributes = ['id', 'key', 'value', 'volume_type_id', 'created_at', + 'updated_at', 'deleted', 'deleted_at'] + + id = sql.Column(sql.Integer, primary_key=True) + key = sql.Column(sql.String(255)) + value = sql.Column(sql.String(255)) + volume_type_id = sql.Column(sql.String(36), + sql.ForeignKey('volume_types.id'), + nullable=False) + volume_type = relationship( + VolumeTypes, + backref="extra_specs", + foreign_keys=volume_type_id, + primaryjoin='and_(' + 'VolumeTypeExtraSpecs.volume_type_id == VolumeTypes.id,' + 'VolumeTypeExtraSpecs.deleted == False)' + ) + + class QualityOfServiceSpecs(core.ModelBase, core.DictBase, models.TimestampMixin): """Represents QoS specs as key/value pairs. diff --git a/tricircle/tests/unit/cinder_apigw/__init__.py b/tricircle/tests/unit/cinder_apigw/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tricircle/tests/unit/cinder_apigw/controllers/__init__.py b/tricircle/tests/unit/cinder_apigw/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tricircle/tests/unit/cinder_apigw/controllers/test_volume.py b/tricircle/tests/unit/cinder_apigw/controllers/test_volume.py new file mode 100644 index 0000000..c8aa07a --- /dev/null +++ b/tricircle/tests/unit/cinder_apigw/controllers/test_volume.py @@ -0,0 +1,271 @@ +# Copyright 2016 OpenStack Foundation. +# 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 mock import patch +import pecan +import unittest + +from tricircle.cinder_apigw.controllers import volume_type +from tricircle.common import context +from tricircle.db import api as db_api +from tricircle.db import core + + +class FakeResponse(object): + def __new__(cls, code=500): + cls.status = code + cls.status_code = code + return super(FakeResponse, cls).__new__(cls) + + +class VolumeTypeTest(unittest.TestCase): + def setUp(self): + core.initialize() + core.ModelBase.metadata.create_all(core.get_engine()) + self.context = context.get_admin_context() + self.project_id = 'test_project' + self.controller = volume_type.VolumeTypeController(self.project_id) + + def _validate_error_code(self, res, code): + self.assertEqual(code, res[res.keys()[0]]['code']) + + @patch.object(pecan, 'response', new=FakeResponse) + @patch.object(context, 'extract_context_from_environ') + def test_post(self, mock_context): + mock_context.return_value = self.context + + body = {'volume_type': {'name': 'vol-type-001', + 'description': 'volume type 001', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**body) + res = db_api.volume_type_get_by_name(self.context, 'vol-type-001') + + self.assertEqual('vol-type-001', res['name']) + self.assertEqual('volume type 001', res['description']) + capabilities = res['extra_specs']['capabilities'] + self.assertEqual('gpu', capabilities) + + # failure case, only admin can create volume type + self.context.is_admin = False + res = self.controller.post(**body) + self._validate_error_code(res, 403) + + self.context.is_admin = True + + # failure case, volume_type body is required + body = {'name': 'vol-type-002'} + res = self.controller.post(**body) + self._validate_error_code(res, 400) + + # failure case, volume type name is empty + body = {'volume_type': {'name': '', + 'description': 'volume type 001', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + res = self.controller.post(**body) + self._validate_error_code(res, 400) + + # failure case, volume type name has more than 255 characters + body = {'volume_type': {'name': ('a' * 500), + 'description': 'volume type 001', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', } + } + } + res = self.controller.post(**body) + self._validate_error_code(res, 400) + + # failure case, volume type description has more than 255 characters + body = {'volume_type': {'name': 'vol-type-001', + 'description': ('a' * 500), + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**body) + self._validate_error_code(res, 400) + + # failure case, is_public is invalid input + body = {'volume_type': {'name': 'vol-type-001', + 'description': 'volume type 001', + 'os-volume-type-access:is_public': 'a', + 'extra_specs': { + 'capabilities': 'gpu', + }}} + res = self.controller.post(**body) + self._validate_error_code(res, 400) + + # failure case, volume type name is unique + body = {'volume_type': {'name': 'vol-type-001', + 'description': 'volume type 001', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + res = self.controller.post(**body) + self._validate_error_code(res, 409) + + @patch.object(pecan, 'response', new=FakeResponse) + @patch.object(context, 'extract_context_from_environ') + def test_get_one(self, mock_context): + mock_context.return_value = self.context + + body = {'volume_type': {'name': 'vol-type-001', + 'description': 'volume type 001', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**body) + vtype = db_api.volume_type_get_by_name(self.context, 'vol-type-001') + res = self.controller.get_one(vtype['id'])['volume_type'] + + self.assertEqual('vol-type-001', res['name']) + self.assertEqual('volume type 001', res['description']) + capabilities = res['extra_specs']['capabilities'] + self.assertEqual('gpu', capabilities) + + # failure case, volume type is not exist. + fake_id = "Fake_ID" + res = self.controller.get_one(fake_id) + self._validate_error_code(res, 404) + + # failure case, the volume type is private. + body = {'volume_type': {'name': 'vol-type-002', + 'description': 'volume type 002', + 'os-volume-type-access:is_public': False, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**body) + vtype = db_api.volume_type_get_by_name(self.context, 'vol-type-002') + self.context.is_admin = False + res = self.controller.get_one(vtype['id']) + self._validate_error_code(res, 404) + + @patch.object(context, 'extract_context_from_environ') + def test_get_all(self, mock_context): + mock_context.return_value = self.context + + volume_type_001 = {'volume_type': {'name': 'vol-type-001', + 'description': 'volume type 001', + 'os-volume-' + 'type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + volume_type_002 = {'volume_type': {'name': 'vol-type-002', + 'description': 'volume type 002', + 'os-volume-' + 'type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**volume_type_001) + self.controller.post(**volume_type_002) + volume_types = self.controller.get_all()['volume_types'] + + self.assertEqual('vol-type-001', volume_types[0]['name']) + self.assertEqual('volume type 001', volume_types[0]['description']) + capabilities_001 = volume_types[0]['extra_specs']['capabilities'] + self.assertEqual('gpu', capabilities_001) + + self.assertEqual('vol-type-002', volume_types[1]['name']) + self.assertEqual('volume type 002', volume_types[1]['description']) + capabilities_002 = volume_types[1]['extra_specs']['capabilities'] + self.assertEqual('gpu', capabilities_002) + + @patch.object(pecan, 'response', new=FakeResponse) + @patch.object(context, 'extract_context_from_environ') + def test_put(self, mock_context): + mock_context.return_value = self.context + + body = {'volume_type': {'name': 'vol-type-001', + 'description': 'volume type 001', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + body_update = {'volume_type': {'name': 'vol-type-002', + 'description': 'volume type 002', + 'os-volume-' + 'type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**body) + vtype = db_api.volume_type_get_by_name(self.context, 'vol-type-001') + res = self.controller.put(vtype['id'], **body_update)['volume_type'] + + self.assertEqual('vol-type-002', res['name']) + self.assertEqual('volume type 002', res['description']) + capabilities = res['extra_specs']['capabilities'] + self.assertEqual('gpu', capabilities) + + # failure case, volume type name, description, is_public + # not None at the same time + body = {'volume_type': {'extra_specs': { + 'capabilities': 'gpu', + }}} + res = self.controller.put(vtype['id'], **body) + self._validate_error_code(res, 400) + # failure case, name exists in db + body = {'volume_type': {'name': 'vol-type-003', + 'description': 'volume type 003', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**body) + res = self.controller.put(vtype['id'], **body) + self._validate_error_code(res, 500) + + @patch.object(pecan, 'response', new=FakeResponse) + @patch.object(db_api, 'volume_type_delete') + @patch.object(context, 'extract_context_from_environ') + def test_delete(self, mock_context, mock_delete): + mock_context.return_value = self.context + mock_delete.return_value = Exception() + + body = {'volume_type': {'name': 'vol-type-001', + 'description': 'volume type 001', + 'os-volume-type-access:is_public': True, + 'extra_specs': { + 'capabilities': 'gpu', + }}} + self.controller.post(**body) + vtype = db_api.volume_type_get_by_name(self.context, 'vol-type-001') + + # failure case, only admin delete create volume type + self.context.is_admin = False + res = self.controller.delete(vtype['id']) + self._validate_error_code(res, 403) + + # failure case, bad request + self.context.is_admin = True + res = self.controller.delete(_id=None) + self._validate_error_code(res, 404) + + res = self.controller.delete(vtype['id']) + self.assertEqual(res.status, 202) + + def tearDown(self): + core.ModelBase.metadata.drop_all(core.get_engine())