diff --git a/cinder/api/openstack/api_version_request.py b/cinder/api/openstack/api_version_request.py index cb273c91b..055d5b5d0 100644 --- a/cinder/api/openstack/api_version_request.py +++ b/cinder/api/openstack/api_version_request.py @@ -58,6 +58,7 @@ REST_API_VERSION_HISTORY = """ * 3.8 - Adds resources from volume_manage and snapshot_manage extensions. * 3.9 - Add backup update interface. * 3.10 - Add group_id filter to list/detail volumes in _get_volumes. + * 3.11 - Add group types and group specs API. """ @@ -66,7 +67,7 @@ REST_API_VERSION_HISTORY = """ # minimum version of the API supported. # Explicitly using /v1 or /v2 enpoints will still work _MIN_API_VERSION = "3.0" -_MAX_API_VERSION = "3.10" +_MAX_API_VERSION = "3.11" _LEGACY_API_VERSION1 = "1.0" _LEGACY_API_VERSION2 = "2.0" diff --git a/cinder/api/openstack/rest_api_version_history.rst b/cinder/api/openstack/rest_api_version_history.rst index 3340a5dc5..0314b89f5 100644 --- a/cinder/api/openstack/rest_api_version_history.rst +++ b/cinder/api/openstack/rest_api_version_history.rst @@ -170,3 +170,7 @@ user documentation. ---- Added the filter parameters ``group_id`` to list/detail volumes requests. + +3.11 +---- + Added group types and group specs API. diff --git a/cinder/api/v3/group_specs.py b/cinder/api/v3/group_specs.py new file mode 100644 index 000000000..d31d517a6 --- /dev/null +++ b/cinder/api/v3/group_specs.py @@ -0,0 +1,149 @@ +# Copyright (c) 2016 EMC Corporation +# +# 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 group types specs controller""" + +import webob + +from cinder.api import common +from cinder.api.openstack import wsgi +from cinder import db +from cinder import exception +from cinder.i18n import _ +from cinder import rpc +from cinder.volume import group_types + + +class GroupTypeSpecsController(wsgi.Controller): + """The group type specs API controller for the OpenStack API.""" + + def _get_group_specs(self, context, group_type_id): + group_specs = db.group_type_specs_get(context, group_type_id) + specs_dict = {} + for key, value in group_specs.items(): + specs_dict[key] = value + return dict(group_specs=specs_dict) + + def _check_type(self, context, group_type_id): + try: + group_types.get_group_type(context, group_type_id) + except exception.GroupTypeNotFound as ex: + raise webob.exc.HTTPNotFound(explanation=ex.msg) + + @wsgi.Controller.api_version('3.11') + def index(self, req, group_type_id): + """Returns the list of group specs for a given group type.""" + context = req.environ['cinder.context'] + self._check_type(context, group_type_id) + return self._get_group_specs(context, group_type_id) + + def _validate_group_specs(self, specs): + """Validating key and value of group specs.""" + for key, value in specs.items(): + if key is not None: + self.validate_string_length(key, 'Key "%s"' % key, + min_length=1, max_length=255) + + if value is not None: + self.validate_string_length(value, 'Value for key "%s"' % key, + min_length=0, max_length=255) + + @wsgi.Controller.api_version('3.11') + @wsgi.response(202) + def create(self, req, group_type_id, body=None): + context = req.environ['cinder.context'] + + self.assert_valid_body(body, 'group_specs') + + self._check_type(context, group_type_id) + specs = body['group_specs'] + self._check_key_names(specs.keys()) + self._validate_group_specs(specs) + + db.group_type_specs_update_or_create(context, + group_type_id, + specs) + notifier_info = dict(type_id=group_type_id, specs=specs) + notifier = rpc.get_notifier('groupTypeSpecs') + notifier.info(context, 'group_type_specs.create', + notifier_info) + return body + + @wsgi.Controller.api_version('3.11') + def update(self, req, group_type_id, id, body=None): + context = req.environ['cinder.context'] + if not body: + expl = _('Request body empty') + raise webob.exc.HTTPBadRequest(explanation=expl) + self._check_type(context, group_type_id) + if id not in body: + expl = _('Request body and URI mismatch') + raise webob.exc.HTTPBadRequest(explanation=expl) + if len(body) > 1: + expl = _('Request body contains too many items') + raise webob.exc.HTTPBadRequest(explanation=expl) + self._check_key_names(body.keys()) + self._validate_group_specs(body) + + db.group_type_specs_update_or_create(context, + group_type_id, + body) + notifier_info = dict(type_id=group_type_id, id=id) + notifier = rpc.get_notifier('groupTypeSpecs') + notifier.info(context, + 'group_type_specs.update', + notifier_info) + return body + + @wsgi.Controller.api_version('3.11') + def show(self, req, group_type_id, id): + """Return a single extra spec item.""" + context = req.environ['cinder.context'] + self._check_type(context, group_type_id) + specs = self._get_group_specs(context, group_type_id) + if id in specs['group_specs']: + return {id: specs['group_specs'][id]} + else: + msg = _("Group Type %(type_id)s has no extra spec with key " + "%(id)s.") % ({'type_id': group_type_id, 'id': id}) + raise webob.exc.HTTPNotFound(explanation=msg) + + @wsgi.Controller.api_version('3.11') + def delete(self, req, group_type_id, id): + """Deletes an existing group spec.""" + context = req.environ['cinder.context'] + self._check_type(context, group_type_id) + + try: + db.group_type_specs_delete(context, group_type_id, id) + except exception.GroupTypeExtraSpecsNotFound as error: + raise webob.exc.HTTPNotFound(explanation=error.msg) + + notifier_info = dict(type_id=group_type_id, id=id) + notifier = rpc.get_notifier('groupTypeSpecs') + notifier.info(context, + 'group_type_specs.delete', + notifier_info) + return webob.Response(status_int=202) + + def _check_key_names(self, keys): + if not common.validate_key_names(keys): + expl = _('Key names can only contain alphanumeric characters, ' + 'underscores, periods, colons and hyphens.') + + raise webob.exc.HTTPBadRequest(explanation=expl) + + +def create_resource(): + return wsgi.Resource(GroupTypeSpecsController()) diff --git a/cinder/api/v3/group_types.py b/cinder/api/v3/group_types.py new file mode 100644 index 000000000..77455e96a --- /dev/null +++ b/cinder/api/v3/group_types.py @@ -0,0 +1,258 @@ +# Copyright (c) 2016 EMC Corporation +# +# 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 group type & group type specs controller.""" + +from oslo_utils import strutils +import six +import webob +from webob import exc + +from cinder.api import common +from cinder.api.openstack import wsgi +from cinder.api.v3.views import group_types as views_types +from cinder import exception +from cinder.i18n import _ +from cinder import rpc +from cinder import utils +from cinder.volume import group_types + + +class GroupTypesController(wsgi.Controller): + """The group types API controller for the OpenStack API.""" + + _view_builder_class = views_types.ViewBuilder + + def _notify_group_type_error(self, context, method, err, + group_type=None, id=None, name=None): + payload = dict( + group_types=group_type, name=name, id=id, error_message=err) + rpc.get_notifier('groupType').error(context, method, payload) + + def _notify_group_type_info(self, context, method, group_type): + payload = dict(group_types=group_type) + rpc.get_notifier('groupType').info(context, method, payload) + + @wsgi.Controller.api_version('3.11') + @wsgi.response(202) + def create(self, req, body): + """Creates a new group type.""" + context = req.environ['cinder.context'] + + self.assert_valid_body(body, 'group_type') + + grp_type = body['group_type'] + name = grp_type.get('name', None) + description = grp_type.get('description') + specs = grp_type.get('group_specs', {}) + is_public = grp_type.get('is_public', True) + + if name is None or len(name.strip()) == 0: + msg = _("Group type name can not be empty.") + raise webob.exc.HTTPBadRequest(explanation=msg) + + utils.check_string_length(name, 'Type name', + min_length=1, max_length=255) + + if description is not None: + utils.check_string_length(description, 'Type description', + min_length=0, max_length=255) + + try: + group_types.create(context, + name, + specs, + is_public, + description=description) + grp_type = group_types.get_group_type_by_name(context, name) + req.cache_resource(grp_type, name='group_types') + self._notify_group_type_info( + context, 'group_type.create', grp_type) + + except exception.GroupTypeExists as err: + self._notify_group_type_error( + context, 'group_type.create', err, group_type=grp_type) + raise webob.exc.HTTPConflict(explanation=six.text_type(err)) + except exception.GroupTypeNotFoundByName as err: + self._notify_group_type_error( + context, 'group_type.create', err, name=name) + raise webob.exc.HTTPNotFound(explanation=err.msg) + + return self._view_builder.show(req, grp_type) + + @wsgi.Controller.api_version('3.11') + def update(self, req, id, body): + # Update description for a given group type. + context = req.environ['cinder.context'] + + self.assert_valid_body(body, 'group_type') + + grp_type = body['group_type'] + description = grp_type.get('description') + name = grp_type.get('name') + is_public = grp_type.get('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: + msg = _("Group type name can not be empty.") + raise webob.exc.HTTPBadRequest(explanation=msg) + + if name is None and description is None and is_public is None: + msg = _("Specify group type name, description or " + "a combination thereof.") + raise webob.exc.HTTPBadRequest(explanation=msg) + + if is_public is not None and not utils.is_valid_boolstr(is_public): + msg = _("Invalid value '%s' for is_public. Accepted values: " + "True or False.") % is_public + raise webob.exc.HTTPBadRequest(explanation=msg) + + if name: + utils.check_string_length(name, 'Type name', + min_length=1, max_length=255) + + if description is not None: + utils.check_string_length(description, 'Type description', + min_length=0, max_length=255) + + try: + group_types.update(context, id, name, description, + is_public=is_public) + # Get the updated + grp_type = group_types.get_group_type(context, id) + req.cache_resource(grp_type, name='group_types') + self._notify_group_type_info( + context, 'group_type.update', grp_type) + + except exception.GroupTypeNotFound as err: + self._notify_group_type_error( + context, 'group_type.update', err, id=id) + raise webob.exc.HTTPNotFound(explanation=six.text_type(err)) + except exception.GroupTypeExists as err: + self._notify_group_type_error( + context, 'group_type.update', err, group_type=grp_type) + raise webob.exc.HTTPConflict(explanation=six.text_type(err)) + except exception.GroupTypeUpdateFailed as err: + self._notify_group_type_error( + context, 'group_type.update', err, group_type=grp_type) + raise webob.exc.HTTPInternalServerError( + explanation=six.text_type(err)) + + return self._view_builder.show(req, grp_type) + + @wsgi.Controller.api_version('3.11') + def delete(self, req, id): + """Deletes an existing group type.""" + context = req.environ['cinder.context'] + + try: + grp_type = group_types.get_group_type(context, id) + group_types.destroy(context, grp_type['id']) + self._notify_group_type_info( + context, 'group_type.delete', grp_type) + except exception.GroupTypeInUse as err: + self._notify_group_type_error( + context, 'group_type.delete', err, group_type=grp_type) + msg = _('Target group type is still in use.') + raise webob.exc.HTTPBadRequest(explanation=msg) + except exception.GroupTypeNotFound as err: + self._notify_group_type_error( + context, 'group_type.delete', err, id=id) + raise webob.exc.HTTPNotFound(explanation=err.msg) + + return webob.Response(status_int=202) + + @wsgi.Controller.api_version('3.11') + def index(self, req): + """Returns the list of group types.""" + limited_types = self._get_group_types(req) + req.cache_resource(limited_types, name='group_types') + return self._view_builder.index(req, limited_types) + + @wsgi.Controller.api_version('3.11') + def show(self, req, id): + """Return a single group type item.""" + context = req.environ['cinder.context'] + + # get default group type + if id is not None and id == 'default': + grp_type = group_types.get_default_group_type() + if not grp_type: + msg = _("Default group type can not be found.") + raise exc.HTTPNotFound(explanation=msg) + req.cache_resource(grp_type, name='group_types') + else: + try: + grp_type = group_types.get_group_type(context, id) + req.cache_resource(grp_type, name='group_types') + except exception.GroupTypeNotFound as error: + raise exc.HTTPNotFound(explanation=error.msg) + + return self._view_builder.show(req, grp_type) + + def _parse_is_public(self, is_public): + """Parse is_public into something usable. + + * True: List public group types only + * False: List private group types only + * None: List both public and private group types + """ + + if is_public is None: + # preserve default value of showing only public types + return True + elif utils.is_none_string(is_public): + return None + else: + try: + return strutils.bool_from_string(is_public, strict=True) + except ValueError: + msg = _('Invalid is_public filter [%s]') % is_public + raise exc.HTTPBadRequest(explanation=msg) + + def _get_group_types(self, req): + """Helper function that returns a list of type dicts.""" + params = req.params.copy() + marker, limit, offset = common.get_pagination_params(params) + sort_keys, sort_dirs = common.get_sort_params(params) + filters = {} + context = req.environ['cinder.context'] + if context.is_admin: + # Only admin has query access to all group types + filters['is_public'] = self._parse_is_public( + req.params.get('is_public', None)) + else: + filters['is_public'] = True + utils.remove_invalid_filter_options(context, + filters, + self._get_grp_type_filter_options() + ) + limited_types = group_types.get_all_group_types(context, + filters=filters, + marker=marker, + limit=limit, + sort_keys=sort_keys, + sort_dirs=sort_dirs, + offset=offset, + list_result=True) + return limited_types + + def _get_grp_type_filter_options(self): + """Return group type search options allowed by non-admin.""" + return ['is_public'] + + +def create_resource(): + return wsgi.Resource(GroupTypesController()) diff --git a/cinder/api/v3/router.py b/cinder/api/v3/router.py index 8638d9e75..72a4cbc86 100644 --- a/cinder/api/v3/router.py +++ b/cinder/api/v3/router.py @@ -29,6 +29,8 @@ from cinder.api.v2 import volume_metadata from cinder.api.v3 import backups from cinder.api.v3 import clusters from cinder.api.v3 import consistencygroups +from cinder.api.v3 import group_specs +from cinder.api.v3 import group_types from cinder.api.v3 import messages from cinder.api.v3 import snapshot_manage from cinder.api.v3 import volume_manage @@ -69,6 +71,17 @@ class APIRouter(cinder.api.openstack.APIRouter): controller=self.resources['types'], member={'action': 'POST'}) + self.resources['group_types'] = group_types.create_resource() + mapper.resource("group_type", "group_types", + controller=self.resources['group_types'], + member={'action': 'POST'}) + + self.resources['group_specs'] = group_specs.create_resource() + mapper.resource("group_spec", "group_specs", + controller=self.resources['group_specs'], + parent_resource=dict(member_name='group_type', + collection_name='group_types')) + self.resources['snapshots'] = snapshots.create_resource(ext_mgr) mapper.resource("snapshot", "snapshots", controller=self.resources['snapshots'], diff --git a/cinder/api/v3/views/group_types.py b/cinder/api/v3/views/group_types.py new file mode 100644 index 000000000..6313d7712 --- /dev/null +++ b/cinder/api/v3/views/group_types.py @@ -0,0 +1,43 @@ +# Copyright 2016 EMC Corporation +# 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 cinder.api import common + + +class ViewBuilder(common.ViewBuilder): + + def show(self, request, group_type, brief=False): + """Trim away extraneous group type attributes.""" + context = request.environ['cinder.context'] + trimmed = dict(id=group_type.get('id'), + name=group_type.get('name'), + description=group_type.get('description'), + is_public=group_type.get('is_public')) + if common.validate_policy( + context, + 'group:access_group_types_specs'): + trimmed['group_specs'] = group_type.get('group_specs') + return trimmed if brief else dict(group_type=trimmed) + + def index(self, request, group_types): + """Index over trimmed group types.""" + group_types_list = [self.show(request, group_type, True) + for group_type in group_types] + group_type_links = self._get_collection_links(request, group_types, + 'group_types') + group_types_dict = dict(group_types=group_types_list) + if group_type_links: + group_types_dict['group_type_links'] = group_type_links + return group_types_dict diff --git a/cinder/common/config.py b/cinder/common/config.py index ac48e7ef9..a1a3add1f 100644 --- a/cinder/common/config.py +++ b/cinder/common/config.py @@ -142,6 +142,8 @@ global_opts = [ 'storage_availability_zone, instead of failing.'), cfg.StrOpt('default_volume_type', help='Default volume type to use'), + cfg.StrOpt('default_group_type', + help='Default group type to use'), cfg.StrOpt('volume_usage_audit_period', default='month', help='Time period for which to generate volume usages. ' diff --git a/cinder/db/api.py b/cinder/db/api.py index 53c9a0e8d..3f67e2042 100644 --- a/cinder/db/api.py +++ b/cinder/db/api.py @@ -622,6 +622,94 @@ def volume_type_access_remove(context, type_id, project_id): #################### +def group_type_create(context, values, projects=None): + """Create a new group type.""" + return IMPL.group_type_create(context, values, projects) + + +def group_type_update(context, group_type_id, values): + return IMPL.group_type_update(context, group_type_id, values) + + +def group_type_get_all(context, inactive=False, filters=None, marker=None, + limit=None, sort_keys=None, sort_dirs=None, + offset=None, list_result=False): + """Get all group types. + + :param context: context to query under + :param inactive: Include inactive group types to the result set + :param filters: Filters for the query in the form of key/value. + :param marker: the last item of the previous page, used to determine the + next page of results to return + :param limit: maximum number of items to return + :param sort_keys: list of attributes by which results should be sorted, + paired with corresponding item in sort_dirs + :param sort_dirs: list of directions in which results should be sorted, + paired with corresponding item in sort_keys + :param list_result: For compatibility, if list_result = True, return a list + instead of dict. + + :is_public: Filter group types based on visibility: + + * **True**: List public group types only + * **False**: List private group types only + * **None**: List both public and private group types + + :returns: list/dict of matching group types + """ + + return IMPL.group_type_get_all(context, inactive, filters, marker=marker, + limit=limit, sort_keys=sort_keys, + sort_dirs=sort_dirs, offset=offset, + list_result=list_result) + + +def group_type_get(context, id, inactive=False, expected_fields=None): + """Get group type by id. + + :param context: context to query under + :param id: Group type id to get. + :param inactive: Consider inactive group types when searching + :param expected_fields: Return those additional fields. + Supported fields are: projects. + :returns: group type + """ + return IMPL.group_type_get(context, id, inactive, expected_fields) + + +def group_type_get_by_name(context, name): + """Get group type by name.""" + return IMPL.group_type_get_by_name(context, name) + + +def group_types_get_by_name_or_id(context, group_type_list): + """Get group types by name or id.""" + return IMPL.group_types_get_by_name_or_id(context, group_type_list) + + +def group_type_destroy(context, id): + """Delete a group type.""" + return IMPL.group_type_destroy(context, id) + + +def group_type_access_get_all(context, type_id): + """Get all group type access of a group type.""" + return IMPL.group_type_access_get_all(context, type_id) + + +def group_type_access_add(context, type_id, project_id): + """Add group type access for project.""" + return IMPL.group_type_access_add(context, type_id, project_id) + + +def group_type_access_remove(context, type_id, project_id): + """Remove group type access for project.""" + return IMPL.group_type_access_remove(context, type_id, project_id) + + +#################### + + def volume_type_extra_specs_get(context, volume_type_id): """Get all extra specs for a volume type.""" return IMPL.volume_type_extra_specs_get(context, volume_type_id) @@ -648,6 +736,32 @@ def volume_type_extra_specs_update_or_create(context, ################### +def group_type_specs_get(context, group_type_id): + """Get all group specs for a group type.""" + return IMPL.group_type_specs_get(context, group_type_id) + + +def group_type_specs_delete(context, group_type_id, key): + """Delete the given group specs item.""" + return IMPL.group_type_specs_delete(context, group_type_id, key) + + +def group_type_specs_update_or_create(context, + group_type_id, + group_specs): + """Create or update group type specs. + + This adds or modifies the key/value pairs specified in the group specs dict + argument. + """ + return IMPL.group_type_specs_update_or_create(context, + group_type_id, + group_specs) + + +################### + + def volume_type_encryption_get(context, volume_type_id, session=None): return IMPL.volume_type_encryption_get(context, volume_type_id, session) diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index 502b79d82..5a5af7788 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -709,6 +709,37 @@ def _dict_with_extra_specs_if_authorized(context, inst_type_query): ################### +def _dict_with_group_specs_if_authorized(context, inst_type_query): + """Convert group type query result to dict with spec and rate_limit. + + Takes a group 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 admin + then we will return an empty extra-specs dict rather than + providing the admin only details. + + Example response with admin context: + + 'group_specs' : [{'key': 'k1', 'value': 'v1', ...}, ...] + to a single dict: + 'group_specs' : {'k1': 'v1'} + + """ + + inst_type_dict = dict(inst_type_query) + if not is_admin_context(context): + del(inst_type_dict['group_specs']) + else: + group_specs = {x['key']: x['value'] + for x in inst_type_query['group_specs']} + inst_type_dict['group_specs'] = group_specs + return inst_type_dict + + +################### + + @require_context def _quota_get(context, project_id, resource, session=None): result = model_query(context, models.Quota, session=session, @@ -2819,6 +2850,48 @@ def volume_type_create(context, values, projects=None): return volume_type_ref +@handle_db_data_error +@require_admin_context +def group_type_create(context, values, projects=None): + """Create a new group type. + + In order to pass in group specs, the values dict should contain a + 'group_specs' key/value pair: + {'group_specs' : {'k1': 'v1', 'k2': 'v2', ...}} + """ + if not values.get('id'): + values['id'] = six.text_type(uuid.uuid4()) + + projects = projects or [] + + session = get_session() + with session.begin(): + try: + _group_type_get_by_name(context, values['name'], session) + raise exception.GroupTypeExists(id=values['name']) + except exception.GroupTypeNotFoundByName: + pass + try: + _group_type_get(context, values['id'], session) + raise exception.GroupTypeExists(id=values['id']) + except exception.GroupTypeNotFound: + pass + try: + values['group_specs'] = _metadata_refs(values.get('group_specs'), + models.GroupTypeSpecs) + group_type_ref = models.GroupTypes() + group_type_ref.update(values) + session.add(group_type_ref) + except Exception as e: + raise db_exc.DBError(e) + for project in set(projects): + access_ref = models.GroupTypeProjects() + access_ref.update({"group_type_id": group_type_ref.id, + "project_id": project}) + access_ref.save(session=session) + return group_type_ref + + def _volume_type_get_query(context, session=None, read_deleted='no', expected_fields=None): expected_fields = expected_fields or [] @@ -2842,6 +2915,29 @@ def _volume_type_get_query(context, session=None, read_deleted='no', return query +def _group_type_get_query(context, session=None, read_deleted='no', + expected_fields=None): + expected_fields = expected_fields or [] + query = model_query(context, + models.GroupTypes, + session=session, + read_deleted=read_deleted).\ + options(joinedload('group_specs')) + + if 'projects' in expected_fields: + query = query.options(joinedload('projects')) + + if not context.is_admin: + the_filter = [models.VolumeTypes.is_public == true()] + projects_attr = getattr(models.VolumeTypes, 'projects') + the_filter.extend([ + projects_attr.any(project_id=context.project_id) + ]) + query = query.filter(or_(*the_filter)) + + return query + + def _process_volume_types_filters(query, filters): context = filters.pop('context', None) if 'is_public' in filters and filters['is_public'] is not None: @@ -2877,6 +2973,41 @@ def _process_volume_types_filters(query, filters): return query +def _process_group_types_filters(query, filters): + context = filters.pop('context', None) + if 'is_public' in filters and filters['is_public'] is not None: + the_filter = [models.GroupTypes.is_public == filters['is_public']] + if filters['is_public'] and context.project_id is not None: + projects_attr = getattr(models.GroupTypes, 'projects') + the_filter.extend([ + 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.GroupTypes, filters): + return + if filters.get('group_specs') is not None: + the_filter = [] + searchdict = filters.get('group_specs') + group_specs = getattr(models.GroupTypes, 'group_specs') + for k, v in searchdict.items(): + the_filter.extend([group_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['group_specs'] + query = query.filter_by(**filters) + return query + + @handle_db_data_error @require_admin_context def volume_type_update(context, volume_type_id, values): @@ -2921,6 +3052,50 @@ def volume_type_update(context, volume_type_id, values): return volume_type_ref +@handle_db_data_error +@require_admin_context +def group_type_update(context, group_type_id, values): + session = get_session() + with session.begin(): + # Check it exists + group_type_ref = _group_type_ref_get(context, + group_type_id, + session) + if not group_type_ref: + raise exception.GroupTypeNotFound(type_id=group_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: + # Group type name is unique. If change to a name that belongs to + # a different group_type , it should be prevented. + check_grp_type = None + try: + check_grp_type = \ + _group_type_get_by_name(context, + values['name'], + session=session) + except exception.GroupTypeNotFoundByName: + pass + else: + if check_grp_type.get('id') != group_type_id: + raise exception.GroupTypeExists(id=values['name']) + + group_type_ref.update(values) + group_type_ref.save(session=session) + + return group_type_ref + + @require_context def volume_type_get_all(context, inactive=False, filters=None, marker=None, limit=None, sort_keys=None, sort_dirs=None, @@ -2973,6 +3148,58 @@ def volume_type_get_all(context, inactive=False, filters=None, marker=None, return result +@require_context +def group_type_get_all(context, inactive=False, filters=None, marker=None, + limit=None, sort_keys=None, sort_dirs=None, + offset=None, list_result=False): + """Returns a dict describing all group_types with name as key. + + If no sort parameters are specified then the returned group types are + sorted first by the 'created_at' key and then by the 'id' key in descending + order. + + :param context: context to query under + :param marker: the last item of the previous page, used to determine the + next page of results to return + :param limit: maximum number of items to return + :param sort_keys: list of attributes by which results should be sorted, + paired with corresponding item in sort_dirs + :param sort_dirs: list of directions in which results should be sorted, + paired with corresponding item in sort_keys + :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 group types + """ + session = get_session() + with session.begin(): + # Add context for _process_group_types_filters + filters = filters or {} + filters['context'] = context + # Generate the query + query = _generate_paginate_query(context, session, marker, limit, + sort_keys, sort_dirs, filters, offset, + models.GroupTypes) + # No group 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_group_specs_if_authorized(context, row) + for row in rows] + return result + result = {row['name']: _dict_with_group_specs_if_authorized(context, + row) + for row in rows} + return result + + def _volume_type_get_id_from_volume_type_query(context, id, session=None): return model_query( context, models.VolumeTypes.id, read_deleted="no", @@ -2980,6 +3207,13 @@ def _volume_type_get_id_from_volume_type_query(context, id, session=None): filter_by(id=id) +def _group_type_get_id_from_group_type_query(context, id, session=None): + return model_query( + context, models.GroupTypes.id, read_deleted="no", + session=session, base_model=models.GroupTypes).\ + filter_by(id=id) + + def _volume_type_get_id_from_volume_type(context, id, session=None): result = _volume_type_get_id_from_volume_type_query( context, id, session=session).first() @@ -2988,6 +3222,14 @@ def _volume_type_get_id_from_volume_type(context, id, session=None): return result[0] +def _group_type_get_id_from_group_type(context, id, session=None): + result = _group_type_get_id_from_group_type_query( + context, id, session=session).first() + if not result: + raise exception.GroupTypeNotFound(group_type_id=id) + return result[0] + + def _volume_type_get_db_object(context, id, session=None, inactive=False, expected_fields=None): read_deleted = "yes" if inactive else "no" @@ -2998,6 +3240,16 @@ def _volume_type_get_db_object(context, id, session=None, inactive=False, return result +def _group_type_get_db_object(context, id, session=None, inactive=False, + expected_fields=None): + read_deleted = "yes" if inactive else "no" + result = _group_type_get_query( + context, session, read_deleted, expected_fields).\ + filter_by(id=id).\ + first() + return result + + @require_context def _volume_type_get(context, id, session=None, inactive=False, expected_fields=None): @@ -3015,6 +3267,23 @@ def _volume_type_get(context, id, session=None, inactive=False, return vtype +@require_context +def _group_type_get(context, id, session=None, inactive=False, + expected_fields=None): + expected_fields = expected_fields or [] + result = _group_type_get_db_object(context, id, session, inactive, + expected_fields) + if not result: + raise exception.GroupTypeNotFound(group_type_id=id) + + gtype = _dict_with_group_specs_if_authorized(context, result) + + if 'projects' in expected_fields: + gtype['projects'] = [p['project_id'] for p in result['projects']] + + return gtype + + @require_context def volume_type_get(context, id, inactive=False, expected_fields=None): """Return a dict describing specific volume_type.""" @@ -3025,12 +3294,28 @@ def volume_type_get(context, id, inactive=False, expected_fields=None): expected_fields=expected_fields) +@require_context +def group_type_get(context, id, inactive=False, expected_fields=None): + """Return a dict describing specific group_type.""" + + return _group_type_get(context, id, + session=None, + inactive=inactive, + expected_fields=expected_fields) + + def _volume_type_get_full(context, id): """Return dict for a specific volume_type with extra_specs and projects.""" return _volume_type_get(context, id, session=None, inactive=False, expected_fields=('extra_specs', 'projects')) +def _group_type_get_full(context, id): + """Return dict for a specific group_type with group_specs and projects.""" + return _group_type_get(context, id, session=None, inactive=False, + expected_fields=('group_specs', 'projects')) + + @require_context def _volume_type_ref_get(context, id, session=None, inactive=False): read_deleted = "yes" if inactive else "no" @@ -3048,6 +3333,23 @@ def _volume_type_ref_get(context, id, session=None, inactive=False): return result +@require_context +def _group_type_ref_get(context, id, session=None, inactive=False): + read_deleted = "yes" if inactive else "no" + result = model_query(context, + models.GroupTypes, + session=session, + read_deleted=read_deleted).\ + options(joinedload('group_specs')).\ + filter_by(id=id).\ + first() + + if not result: + raise exception.GroupTypeNotFound(group_type_id=id) + + return result + + @require_context def _volume_type_get_by_name(context, name, session=None): result = model_query(context, models.VolumeTypes, session=session).\ @@ -3061,6 +3363,19 @@ def _volume_type_get_by_name(context, name, session=None): return _dict_with_extra_specs_if_authorized(context, result) +@require_context +def _group_type_get_by_name(context, name, session=None): + result = model_query(context, models.GroupTypes, session=session).\ + options(joinedload('group_specs')).\ + filter_by(name=name).\ + first() + + if not result: + raise exception.GroupTypeNotFoundByName(group_type_name=name) + + return _dict_with_group_specs_if_authorized(context, result) + + @require_context def volume_type_get_by_name(context, name): """Return a dict describing specific volume_type.""" @@ -3068,6 +3383,13 @@ def volume_type_get_by_name(context, name): return _volume_type_get_by_name(context, name) +@require_context +def group_type_get_by_name(context, name): + """Return a dict describing specific group_type.""" + + return _group_type_get_by_name(context, name) + + @require_context def volume_types_get_by_name_or_id(context, volume_type_list): """Return a dict describing specific volume_type.""" @@ -3081,6 +3403,19 @@ def volume_types_get_by_name_or_id(context, volume_type_list): return req_volume_types +@require_context +def group_types_get_by_name_or_id(context, group_type_list): + """Return a dict describing specific group_type.""" + req_group_types = [] + for grp_t in group_type_list: + if not uuidutils.is_uuid_like(grp_t): + grp_type = _group_type_get_by_name(context, grp_t) + else: + grp_type = _group_type_get(context, grp_t) + req_group_types.append(grp_type) + return req_group_types + + @require_admin_context def volume_type_qos_associations_get(context, qos_specs_id, inactive=False): read_deleted = "yes" if inactive else "no" @@ -3205,6 +3540,31 @@ def volume_type_destroy(context, id): return updated_values +@require_admin_context +@_retry_on_deadlock +def group_type_destroy(context, id): + session = get_session() + with session.begin(): + _group_type_get(context, id, session) + # TODO(xyang): Uncomment the following after groups table is added. + # results = model_query(context, models.Group, session=session). \ + # filter_by(group_type_id=id).all() + # if results: + # LOG.error(_LE('GroupType %s deletion failed, ' + # 'GroupType in use.'), id) + # raise exception.GroupTypeInUse(group_type_id=id) + model_query(context, models.GroupTypes, session=session).\ + filter_by(id=id).\ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + model_query(context, models.GroupTypeSpecs, session=session).\ + filter_by(group_type_id=id).\ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + @require_context def volume_get_active_by_window(context, begin, @@ -3235,6 +3595,11 @@ def _volume_type_access_query(context, session=None): read_deleted="int_no") +def _group_type_access_query(context, session=None): + return model_query(context, models.GroupTypeProjects, session=session, + read_deleted="int_no") + + @require_admin_context def volume_type_access_get_all(context, type_id): volume_type_id = _volume_type_get_id_from_volume_type(context, type_id) @@ -3242,6 +3607,13 @@ def volume_type_access_get_all(context, type_id): filter_by(volume_type_id=volume_type_id).all() +@require_admin_context +def group_type_access_get_all(context, type_id): + group_type_id = _group_type_get_id_from_group_type(context, type_id) + return _group_type_access_query(context).\ + filter_by(group_type_id=group_type_id).all() + + @require_admin_context def volume_type_access_add(context, type_id, project_id): """Add given tenant to the volume type access list.""" @@ -3261,6 +3633,25 @@ def volume_type_access_add(context, type_id, project_id): return access_ref +@require_admin_context +def group_type_access_add(context, type_id, project_id): + """Add given tenant to the group type access list.""" + group_type_id = _group_type_get_id_from_group_type(context, type_id) + + access_ref = models.GroupTypeProjects() + access_ref.update({"group_type_id": group_type_id, + "project_id": project_id}) + + session = get_session() + with session.begin(): + try: + access_ref.save(session=session) + except db_exc.DBDuplicateEntry: + raise exception.GroupTypeAccessExists(group_type_id=type_id, + project_id=project_id) + return access_ref + + @require_admin_context def volume_type_access_remove(context, type_id, project_id): """Remove given tenant from the volume type access list.""" @@ -3275,6 +3666,20 @@ def volume_type_access_remove(context, type_id, project_id): volume_type_id=type_id, project_id=project_id) +@require_admin_context +def group_type_access_remove(context, type_id, project_id): + """Remove given tenant from the group type access list.""" + group_type_id = _group_type_get_id_from_group_type(context, type_id) + + count = (_group_type_access_query(context). + filter_by(group_type_id=group_type_id). + filter_by(project_id=project_id). + soft_delete(synchronize_session=False)) + if count == 0: + raise exception.GroupTypeAccessNotFound( + group_type_id=type_id, project_id=project_id) + + #################### @@ -3349,6 +3754,77 @@ def volume_type_extra_specs_update_or_create(context, volume_type_id, #################### +def _group_type_specs_query(context, group_type_id, session=None): + return model_query(context, models.GroupTypeSpecs, session=session, + read_deleted="no").\ + filter_by(group_type_id=group_type_id) + + +@require_context +def group_type_specs_get(context, group_type_id): + rows = _group_type_specs_query(context, group_type_id).\ + all() + + result = {} + for row in rows: + result[row['key']] = row['value'] + + return result + + +@require_context +def group_type_specs_delete(context, group_type_id, key): + session = get_session() + with session.begin(): + _group_type_specs_get_item(context, group_type_id, key, + session) + _group_type_specs_query(context, group_type_id, session).\ + filter_by(key=key).\ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + +@require_context +def _group_type_specs_get_item(context, group_type_id, key, + session=None): + result = _group_type_specs_query( + context, group_type_id, session=session).\ + filter_by(key=key).\ + first() + + if not result: + raise exception.GroupTypeSpecsNotFound( + group_specs_key=key, + group_type_id=group_type_id) + + return result + + +@handle_db_data_error +@require_context +def group_type_specs_update_or_create(context, group_type_id, + specs): + session = get_session() + with session.begin(): + spec_ref = None + for key, value in specs.items(): + try: + spec_ref = _group_type_specs_get_item( + context, group_type_id, key, session) + except exception.GroupTypeSpecsNotFound: + spec_ref = models.GroupTypeSpecs() + spec_ref.update({"key": key, "value": value, + "group_type_id": group_type_id, + "deleted": False}) + spec_ref.save(session=session) + + return specs + + +#################### + + @require_admin_context def qos_specs_create(context, values): """Create a new QoS specs. @@ -4921,7 +5397,9 @@ PAGINATION_HELPERS = { _process_consistencygroups_filters, _consistencygroup_get), models.Message: (_messages_get_query, _process_messages_filters, - _message_get) + _message_get), + models.GroupTypes: (_group_type_get_query, _process_group_types_filters, + _group_type_get_db_object), } @@ -5109,6 +5587,7 @@ def get_model_for_versioned_object(versioned_object): 'BackupImport': models.Backup, 'VolumeType': models.VolumeTypes, 'CGSnapshot': models.Cgsnapshot, + 'GroupType': models.GroupTypes, } if isinstance(versioned_object, six.string_types): @@ -5127,6 +5606,7 @@ def _get_get_method(model): models.ConsistencyGroup: consistencygroup_get, models.VolumeTypes: _volume_type_get_full, models.QualityOfServiceSpecs: qos_specs_get, + models.GroupTypes: _group_type_get_full, } if model in GET_EXCEPTIONS: diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/077_add_group_types_and_group_specs_table.py b/cinder/db/sqlalchemy/migrate_repo/versions/077_add_group_types_and_group_specs_table.py new file mode 100644 index 000000000..2357dbf3a --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/077_add_group_types_and_group_specs_table.py @@ -0,0 +1,75 @@ +# 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 sqlalchemy import Boolean, Column, DateTime, Integer +from sqlalchemy import ForeignKey, MetaData, String, Table, UniqueConstraint + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + # New table + group_types = Table( + 'group_types', + meta, + Column('id', String(36), primary_key=True, nullable=False), + Column('name', String(255), nullable=False), + Column('description', String(255)), + Column('created_at', DateTime(timezone=False)), + Column('updated_at', DateTime(timezone=False)), + Column('deleted_at', DateTime(timezone=False)), + Column('deleted', Boolean), + Column('is_public', Boolean), + mysql_engine='InnoDB', + mysql_charset='utf8', + ) + + group_types.create() + + # New table + group_type_specs = Table( + 'group_type_specs', + meta, + Column('id', Integer, primary_key=True, nullable=False), + Column('key', String(255)), + Column('value', String(255)), + Column('group_type_id', String(36), + ForeignKey('group_types.id'), + nullable=False), + Column('created_at', DateTime(timezone=False)), + Column('updated_at', DateTime(timezone=False)), + Column('deleted_at', DateTime(timezone=False)), + Column('deleted', Boolean), + mysql_engine='InnoDB', + mysql_charset='utf8', + ) + + group_type_specs.create() + + # New table + group_type_projects = Table( + 'group_type_projects', meta, + Column('id', Integer, primary_key=True, nullable=False), + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('deleted_at', DateTime), + Column('group_type_id', String(36), + ForeignKey('group_types.id')), + Column('project_id', String(length=255)), + Column('deleted', Boolean(create_constraint=True, name=None)), + UniqueConstraint('group_type_id', 'project_id', 'deleted'), + mysql_engine='InnoDB', + mysql_charset='utf8', + ) + + group_type_projects.create() diff --git a/cinder/db/sqlalchemy/models.py b/cinder/db/sqlalchemy/models.py index fe6a7a0a0..defca1cb6 100644 --- a/cinder/db/sqlalchemy/models.py +++ b/cinder/db/sqlalchemy/models.py @@ -323,6 +323,22 @@ class VolumeTypes(BASE, CinderBase): 'VolumeTypes.deleted == False)') +class GroupTypes(BASE, CinderBase): + """Represent possible group_types of groups offered.""" + __tablename__ = "group_types" + id = Column(String(36), primary_key=True) + name = Column(String(255)) + description = Column(String(255)) + is_public = Column(Boolean, default=True) + # TODO(xyang): Uncomment the following after groups table is added. + # groups = relationship(Group, + # backref=backref('group_type', uselist=False), + # foreign_keys=id, + # primaryjoin='and_(' + # 'Group.group_type_id == GroupTypes.id, ' + # 'GroupTypes.deleted == False)') + + class VolumeTypeProjects(BASE, CinderBase): """Represent projects associated volume_types.""" __tablename__ = "volume_type_projects" @@ -345,6 +361,28 @@ class VolumeTypeProjects(BASE, CinderBase): 'VolumeTypeProjects.deleted == 0)') +class GroupTypeProjects(BASE, CinderBase): + """Represent projects associated group_types.""" + __tablename__ = "group_type_projects" + __table_args__ = (schema.UniqueConstraint( + "group_type_id", "project_id", "deleted", + name="uniq_group_type_projects0group_type_id0project_id0deleted"), + ) + id = Column(Integer, primary_key=True) + group_type_id = Column(Integer, ForeignKey('group_types.id'), + nullable=False) + project_id = Column(String(255)) + deleted = Column(Integer, default=0) + + group_type = relationship( + GroupTypes, + backref="projects", + foreign_keys=group_type_id, + primaryjoin='and_(' + 'GroupTypeProjects.group_type_id == GroupTypes.id,' + 'GroupTypeProjects.deleted == 0)') + + class VolumeTypeExtraSpecs(BASE, CinderBase): """Represents additional specs as key/value pairs for a volume_type.""" __tablename__ = 'volume_type_extra_specs' @@ -364,6 +402,25 @@ class VolumeTypeExtraSpecs(BASE, CinderBase): ) +class GroupTypeSpecs(BASE, CinderBase): + """Represents additional specs as key/value pairs for a group_type.""" + __tablename__ = 'group_type_specs' + id = Column(Integer, primary_key=True) + key = Column(String(255)) + value = Column(String(255)) + group_type_id = Column(String(36), + ForeignKey('group_types.id'), + nullable=False) + group_type = relationship( + GroupTypes, + backref="group_specs", + foreign_keys=group_type_id, + primaryjoin='and_(' + 'GroupTypeSpecs.group_type_id == GroupTypes.id,' + 'GroupTypeSpecs.deleted == False)' + ) + + class QualityOfServiceSpecs(BASE, CinderBase): """Represents QoS specs as key/value pairs. diff --git a/cinder/exception.py b/cinder/exception.py index da04b7123..be8099705 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -204,6 +204,10 @@ class InvalidVolumeType(Invalid): message = _("Invalid volume type: %(reason)s") +class InvalidGroupType(Invalid): + message = _("Invalid group type: %(reason)s") + + class InvalidVolume(Invalid): message = _("Invalid volume: %(reason)s") @@ -358,6 +362,30 @@ class VolumeTypeInUse(CinderException): "volumes present with the type.") +class GroupTypeNotFound(NotFound): + message = _("Group type %(group_type_id)s could not be found.") + + +class GroupTypeNotFoundByName(GroupTypeNotFound): + message = _("Group type with name %(group_type_name)s " + "could not be found.") + + +class GroupTypeAccessNotFound(NotFound): + message = _("Group type access not found for %(group_type_id)s / " + "%(project_id)s combination.") + + +class GroupTypeSpecsNotFound(NotFound): + message = _("Group Type %(group_type_id)s has no specs with " + "key %(group_specs_key)s.") + + +class GroupTypeInUse(CinderException): + message = _("Group Type %(group_type_id)s deletion is not allowed with " + "groups present with the type.") + + class SnapshotNotFound(NotFound): message = _("Snapshot %(snapshot_id)s could not be found.") @@ -503,6 +531,23 @@ class VolumeTypeEncryptionNotFound(NotFound): message = _("Volume type encryption for type %(type_id)s does not exist.") +class GroupTypeExists(Duplicate): + message = _("Group Type %(id)s already exists.") + + +class GroupTypeAccessExists(Duplicate): + message = _("Group type access for %(group_type_id)s / " + "%(project_id)s combination already exists.") + + +class GroupTypeEncryptionExists(Invalid): + message = _("Group type encryption for type %(type_id)s already exists.") + + +class GroupTypeEncryptionNotFound(NotFound): + message = _("Group type encryption for type %(type_id)s does not exist.") + + class MalformedRequestBody(CinderException): message = _("Malformed message body: %(reason)s") @@ -595,6 +640,15 @@ class VolumeTypeUpdateFailed(CinderException): message = _("Cannot update volume_type %(id)s") +class GroupTypeCreateFailed(CinderException): + message = _("Cannot create group_type with " + "name %(name)s and specs %(group_specs)s") + + +class GroupTypeUpdateFailed(CinderException): + message = _("Cannot update group_type %(id)s") + + class UnknownCmd(VolumeDriverException): message = _("Unknown or unsupported command %(cmd)s") diff --git a/cinder/objects/__init__.py b/cinder/objects/__init__.py index 5d5985565..e4f449242 100644 --- a/cinder/objects/__init__.py +++ b/cinder/objects/__init__.py @@ -35,3 +35,4 @@ def register_all(): __import__('cinder.objects.volume') __import__('cinder.objects.volume_attachment') __import__('cinder.objects.volume_type') + __import__('cinder.objects.group_type') diff --git a/cinder/objects/base.py b/cinder/objects/base.py index 78764c391..aa9e7aa89 100644 --- a/cinder/objects/base.py +++ b/cinder/objects/base.py @@ -111,6 +111,7 @@ OBJ_VERSIONS.add('1.7', {'Cluster': '1.0', 'ClusterList': '1.0', 'Service': '1.4', 'Volume': '1.4', 'ConsistencyGroup': '1.3'}) OBJ_VERSIONS.add('1.8', {'RequestSpec': '1.0', 'VolumeProperties': '1.0'}) +OBJ_VERSIONS.add('1.9', {'GroupType': '1.0', 'GroupTypeList': '1.0'}) class CinderObjectRegistry(base.VersionedObjectRegistry): diff --git a/cinder/objects/group_type.py b/cinder/objects/group_type.py new file mode 100644 index 000000000..bae6b9b7d --- /dev/null +++ b/cinder/objects/group_type.py @@ -0,0 +1,121 @@ +# Copyright 2016 EMC Corporation +# +# 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_versionedobjects import fields + +from cinder import exception +from cinder.i18n import _ +from cinder import objects +from cinder.objects import base +from cinder.volume import group_types + + +OPTIONAL_FIELDS = ['group_specs', 'projects'] + + +@base.CinderObjectRegistry.register +class GroupType(base.CinderPersistentObject, base.CinderObject, + base.CinderObjectDictCompat, base.CinderComparableObject): + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'id': fields.UUIDField(), + 'name': fields.StringField(nullable=True), + 'description': fields.StringField(nullable=True), + 'is_public': fields.BooleanField(default=True, nullable=True), + 'projects': fields.ListOfStringsField(nullable=True), + 'group_specs': fields.DictOfNullableStringsField(nullable=True), + } + + @classmethod + def _get_expected_attrs(cls, context): + return 'group_specs', 'projects' + + @staticmethod + def _from_db_object(context, type, db_type, expected_attrs=None): + if expected_attrs is None: + expected_attrs = [] + for name, field in type.fields.items(): + if name in OPTIONAL_FIELDS: + continue + value = db_type[name] + if isinstance(field, fields.IntegerField): + value = value or 0 + type[name] = value + + # Get data from db_type object that was queried by joined query + # from DB + if 'group_specs' in expected_attrs: + type.group_specs = {} + specs = db_type.get('group_specs') + if specs and isinstance(specs, list): + type.group_specs = {item['key']: item['value'] + for item in specs} + elif specs and isinstance(specs, dict): + type.group_specs = specs + if 'projects' in expected_attrs: + type.projects = db_type.get('projects', []) + + type._context = context + type.obj_reset_changes() + return type + + def create(self): + if self.obj_attr_is_set('id'): + raise exception.ObjectActionError(action='create', + reason=_('already created')) + db_group_type = group_types.create(self._context, self.name, + self.group_specs, + self.is_public, self.projects, + self.description) + self._from_db_object(self._context, self, db_group_type) + + def save(self): + updates = self.cinder_obj_get_changes() + if updates: + group_types.update(self._context, self.id, self.name, + self.description) + self.obj_reset_changes() + + def destroy(self): + with self.obj_as_admin(): + group_types.destroy(self._context, self.id) + + +@base.CinderObjectRegistry.register +class GroupTypeList(base.ObjectListBase, base.CinderObject): + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'objects': fields.ListOfObjectsField('GroupType'), + } + + child_versions = { + '1.0': '1.0', + } + + @classmethod + def get_all(cls, context, inactive=0, filters=None, marker=None, + limit=None, sort_keys=None, sort_dirs=None, offset=None): + types = group_types.get_all_group_types(context, inactive, filters, + marker=marker, limit=limit, + sort_keys=sort_keys, + sort_dirs=sort_dirs, + offset=offset) + expected_attrs = GroupType._get_expected_attrs(context) + return base.obj_make_list(context, cls(context), + objects.GroupType, types.values(), + expected_attrs=expected_attrs) diff --git a/cinder/tests/unit/api/v3/test_group_types.py b/cinder/tests/unit/api/v3/test_group_types.py new file mode 100644 index 000000000..a9784bfba --- /dev/null +++ b/cinder/tests/unit/api/v3/test_group_types.py @@ -0,0 +1,535 @@ +# Copyright 2016 EMC Corporation +# 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 uuid + +import mock +from oslo_utils import timeutils +import six +import webob + +import cinder.api.common as common +from cinder.api.v3 import group_types as v3_group_types +from cinder.api.v3.views import group_types as views_types +from cinder import context +from cinder import exception +from cinder import test +from cinder.tests.unit.api import fakes +from cinder.tests.unit import fake_constants as fake +from cinder.volume import group_types + +GROUP_TYPE_MICRO_VERSION = '3.11' + + +def stub_group_type(id): + specs = { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5" + } + return dict( + id=id, + name='group_type_%s' % six.text_type(id), + description='group_type_desc_%s' % six.text_type(id), + group_specs=specs, + ) + + +def return_group_types_get_all_types(context, filters=None, marker=None, + limit=None, sort_keys=None, + sort_dirs=None, offset=None, + list_result=False): + result = dict(group_type_1=stub_group_type(1), + group_type_2=stub_group_type(2), + group_type_3=stub_group_type(3) + ) + if list_result: + return list(result.values()) + return result + + +def return_empty_group_types_get_all_types(context, filters=None, marker=None, + limit=None, sort_keys=None, + sort_dirs=None, offset=None, + list_result=False): + if list_result: + return [] + return {} + + +def return_group_types_get_group_type(context, id): + if id == fake.WILL_NOT_BE_FOUND_ID: + raise exception.GroupTypeNotFound(group_type_id=id) + return stub_group_type(id) + + +def return_group_types_get_default(): + return stub_group_type(1) + + +def return_group_types_get_default_not_found(): + return {} + + +class GroupTypesApiTest(test.TestCase): + + def _create_group_type(self, group_type_name, group_specs=None, + is_public=True, projects=None): + return group_types.create(self.ctxt, group_type_name, group_specs, + is_public, projects).get('id') + + def setUp(self): + super(GroupTypesApiTest, self).setUp() + self.controller = v3_group_types.GroupTypesController() + self.ctxt = context.RequestContext(user_id=fake.USER_ID, + project_id=fake.PROJECT_ID, + is_admin=True) + self.type_id1 = self._create_group_type('group_type1', + {'key1': 'value1'}) + self.type_id2 = self._create_group_type('group_type2', + {'key2': 'value2'}) + self.type_id3 = self._create_group_type('group_type3', + {'key3': 'value3'}, False, + [fake.PROJECT_ID]) + + def test_group_types_index(self): + self.stubs.Set(group_types, 'get_all_group_types', + return_group_types_get_all_types) + + req = fakes.HTTPRequest.blank('/v3/%s/group_types' % fake.PROJECT_ID, + use_admin_context=True, + version=GROUP_TYPE_MICRO_VERSION) + res_dict = self.controller.index(req) + + self.assertEqual(3, len(res_dict['group_types'])) + + expected_names = ['group_type_1', 'group_type_2', 'group_type_3'] + actual_names = map(lambda e: e['name'], res_dict['group_types']) + self.assertEqual(set(expected_names), set(actual_names)) + for entry in res_dict['group_types']: + self.assertEqual('value1', entry['group_specs']['key1']) + + def test_group_types_index_no_data(self): + self.stubs.Set(group_types, 'get_all_group_types', + return_empty_group_types_get_all_types) + + req = fakes.HTTPRequest.blank('/v3/%s/group_types' % fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + res_dict = self.controller.index(req) + + self.assertEqual(0, len(res_dict['group_types'])) + + def test_group_types_index_with_limit(self): + req = fakes.HTTPRequest.blank('/v3/%s/group_types?limit=1' % + fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + + self.assertEqual(1, len(res['group_types'])) + self.assertEqual(self.type_id3, res['group_types'][0]['id']) + + expect_next_link = ('http://localhost/v3/%s/group_types?limit=1' + '&marker=%s' % + (fake.PROJECT_ID, res['group_types'][0]['id'])) + self.assertEqual(expect_next_link, res['group_type_links'][0]['href']) + + def test_group_types_index_with_offset(self): + req = fakes.HTTPRequest.blank( + '/v3/%s/group_types?offset=1' % fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + + self.assertEqual(2, len(res['group_types'])) + + def test_group_types_index_with_offset_out_of_range(self): + url = '/v3/%s/group_types?offset=424366766556787' % fake.PROJECT_ID + req = fakes.HTTPRequest.blank(url, version=GROUP_TYPE_MICRO_VERSION) + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.index, req) + + def test_group_types_index_with_limit_and_offset(self): + req = fakes.HTTPRequest.blank( + '/v3/%s/group_types?limit=2&offset=1' % fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + + self.assertEqual(2, len(res['group_types'])) + self.assertEqual(self.type_id2, res['group_types'][0]['id']) + self.assertEqual(self.type_id1, res['group_types'][1]['id']) + + def test_group_types_index_with_limit_and_marker(self): + req = fakes.HTTPRequest.blank('/v3/%s/group_types?limit=1' + '&marker=%s' % + (fake.PROJECT_ID, + self.type_id2), + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + + self.assertEqual(1, len(res['group_types'])) + self.assertEqual(self.type_id1, res['group_types'][0]['id']) + + def test_group_types_index_with_valid_filter(self): + req = fakes.HTTPRequest.blank( + '/v3/%s/group_types?is_public=True' % fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + + self.assertEqual(3, len(res['group_types'])) + self.assertEqual(self.type_id3, res['group_types'][0]['id']) + self.assertEqual(self.type_id2, res['group_types'][1]['id']) + self.assertEqual(self.type_id1, res['group_types'][2]['id']) + + def test_group_types_index_with_invalid_filter(self): + req = fakes.HTTPRequest.blank( + '/v3/%s/group_types?id=%s' % (fake.PROJECT_ID, self.type_id1), + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + + self.assertEqual(3, len(res['group_types'])) + + def test_group_types_index_with_sort_keys(self): + req = fakes.HTTPRequest.blank('/v3/%s/group_types?sort=id' % + fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + expect_result = [self.type_id1, self.type_id2, self.type_id3] + expect_result.sort(reverse=True) + + self.assertEqual(3, len(res['group_types'])) + self.assertEqual(expect_result[0], res['group_types'][0]['id']) + self.assertEqual(expect_result[1], res['group_types'][1]['id']) + self.assertEqual(expect_result[2], res['group_types'][2]['id']) + + def test_group_types_index_with_sort_and_limit(self): + req = fakes.HTTPRequest.blank( + '/v3/%s/group_types?sort=id&limit=2' % fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + expect_result = [self.type_id1, self.type_id2, self.type_id3] + expect_result.sort(reverse=True) + + self.assertEqual(2, len(res['group_types'])) + self.assertEqual(expect_result[0], res['group_types'][0]['id']) + self.assertEqual(expect_result[1], res['group_types'][1]['id']) + + def test_group_types_index_with_sort_keys_and_sort_dirs(self): + req = fakes.HTTPRequest.blank( + '/v3/%s/group_types?sort=id:asc' % fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.environ['cinder.context'] = self.ctxt + res = self.controller.index(req) + expect_result = [self.type_id1, self.type_id2, self.type_id3] + expect_result.sort() + + self.assertEqual(3, len(res['group_types'])) + self.assertEqual(expect_result[0], res['group_types'][0]['id']) + self.assertEqual(expect_result[1], res['group_types'][1]['id']) + self.assertEqual(expect_result[2], res['group_types'][2]['id']) + + def test_group_types_show(self): + self.stubs.Set(group_types, 'get_group_type', + return_group_types_get_group_type) + + type_id = six.text_type(uuid.uuid4()) + req = fakes.HTTPRequest.blank('/v3/%s/group_types/' % fake.PROJECT_ID + + type_id, + version=GROUP_TYPE_MICRO_VERSION) + res_dict = self.controller.show(req, type_id) + + self.assertEqual(1, len(res_dict)) + self.assertEqual(type_id, res_dict['group_type']['id']) + type_name = 'group_type_' + type_id + self.assertEqual(type_name, res_dict['group_type']['name']) + + def test_group_types_show_pre_microversion(self): + self.stubs.Set(group_types, 'get_group_type', + return_group_types_get_group_type) + + type_id = six.text_type(uuid.uuid4()) + req = fakes.HTTPRequest.blank('/v3/%s/group_types/' % fake.PROJECT_ID + + type_id, + version='3.5') + + self.assertRaises(exception.VersionNotFoundForAPIMethod, + self.controller.show, req, type_id) + + def test_group_types_show_not_found(self): + self.stubs.Set(group_types, 'get_group_type', + return_group_types_get_group_type) + + req = fakes.HTTPRequest.blank('/v3/%s/group_types/%s' % + (fake.PROJECT_ID, + fake.WILL_NOT_BE_FOUND_ID), + version=GROUP_TYPE_MICRO_VERSION) + self.assertRaises(webob.exc.HTTPNotFound, self.controller.show, + req, fake.WILL_NOT_BE_FOUND_ID) + + def test_get_default(self): + self.stubs.Set(group_types, 'get_default_group_type', + return_group_types_get_default) + req = fakes.HTTPRequest.blank('/v3/%s/group_types/default' % + fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.method = 'GET' + res_dict = self.controller.show(req, 'default') + self.assertEqual(1, len(res_dict)) + self.assertEqual('group_type_1', res_dict['group_type']['name']) + self.assertEqual('group_type_desc_1', + res_dict['group_type']['description']) + + def test_get_default_not_found(self): + self.stubs.Set(group_types, 'get_default_group_type', + return_group_types_get_default_not_found) + req = fakes.HTTPRequest.blank('/v3/%s/group_types/default' % + fake.PROJECT_ID, + version=GROUP_TYPE_MICRO_VERSION) + req.method = 'GET' + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, req, 'default') + + def test_view_builder_show(self): + view_builder = views_types.ViewBuilder() + + now = timeutils.utcnow().isoformat() + raw_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + deleted=False, + created_at=now, + updated_at=now, + group_specs={}, + deleted_at=None, + id=42, + ) + + request = fakes.HTTPRequest.blank("/v3", + version=GROUP_TYPE_MICRO_VERSION) + output = view_builder.show(request, raw_group_type) + + self.assertIn('group_type', output) + expected_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + id=42, + ) + self.assertDictMatch(expected_group_type, output['group_type']) + + def test_view_builder_show_admin(self): + view_builder = views_types.ViewBuilder() + + now = timeutils.utcnow().isoformat() + raw_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + deleted=False, + created_at=now, + updated_at=now, + group_specs={}, + deleted_at=None, + id=42, + ) + + request = fakes.HTTPRequest.blank("/v3", use_admin_context=True, + version=GROUP_TYPE_MICRO_VERSION) + output = view_builder.show(request, raw_group_type) + + self.assertIn('group_type', output) + expected_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + group_specs={}, + id=42, + ) + self.assertDictMatch(expected_group_type, output['group_type']) + + def __test_view_builder_show_qos_specs_id_policy(self): + with mock.patch.object(common, + 'validate_policy', + side_effect=[False, True]): + view_builder = views_types.ViewBuilder() + now = timeutils.utcnow().isoformat() + raw_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + deleted=False, + created_at=now, + updated_at=now, + deleted_at=None, + id=42, + ) + + request = fakes.HTTPRequest.blank("/v3", + version=GROUP_TYPE_MICRO_VERSION) + output = view_builder.show(request, raw_group_type) + + self.assertIn('group_type', output) + expected_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + id=42, + ) + self.assertDictMatch(expected_group_type, output['group_type']) + + def test_view_builder_show_group_specs_policy(self): + with mock.patch.object(common, + 'validate_policy', + side_effect=[True, False]): + view_builder = views_types.ViewBuilder() + now = timeutils.utcnow().isoformat() + raw_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + deleted=False, + created_at=now, + updated_at=now, + group_specs={}, + deleted_at=None, + id=42, + ) + + request = fakes.HTTPRequest.blank("/v3", + version=GROUP_TYPE_MICRO_VERSION) + output = view_builder.show(request, raw_group_type) + + self.assertIn('group_type', output) + expected_group_type = dict( + name='new_type', + description='new_type_desc', + group_specs={}, + is_public=True, + id=42, + ) + self.assertDictMatch(expected_group_type, output['group_type']) + + def test_view_builder_show_pass_all_policy(self): + with mock.patch.object(common, + 'validate_policy', + side_effect=[True, True]): + view_builder = views_types.ViewBuilder() + now = timeutils.utcnow().isoformat() + raw_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + deleted=False, + created_at=now, + updated_at=now, + group_specs={}, + deleted_at=None, + id=42, + ) + + request = fakes.HTTPRequest.blank("/v3", + version=GROUP_TYPE_MICRO_VERSION) + output = view_builder.show(request, raw_group_type) + + self.assertIn('group_type', output) + expected_group_type = dict( + name='new_type', + description='new_type_desc', + group_specs={}, + is_public=True, + id=42, + ) + self.assertDictMatch(expected_group_type, output['group_type']) + + def test_view_builder_list(self): + view_builder = views_types.ViewBuilder() + + now = timeutils.utcnow().isoformat() + raw_group_types = [] + for i in range(0, 10): + raw_group_types.append( + dict( + name='new_type', + description='new_type_desc', + is_public=True, + deleted=False, + created_at=now, + updated_at=now, + group_specs={}, + deleted_at=None, + id=42 + i + ) + ) + + request = fakes.HTTPRequest.blank("/v3", + version=GROUP_TYPE_MICRO_VERSION) + output = view_builder.index(request, raw_group_types) + + self.assertIn('group_types', output) + for i in range(0, 10): + expected_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + id=42 + i + ) + self.assertDictMatch(expected_group_type, + output['group_types'][i]) + + def test_view_builder_list_admin(self): + view_builder = views_types.ViewBuilder() + + now = timeutils.utcnow().isoformat() + raw_group_types = [] + for i in range(0, 10): + raw_group_types.append( + dict( + name='new_type', + description='new_type_desc', + is_public=True, + deleted=False, + created_at=now, + updated_at=now, + group_specs={}, + deleted_at=None, + id=42 + i + ) + ) + + request = fakes.HTTPRequest.blank("/v3", use_admin_context=True, + version=GROUP_TYPE_MICRO_VERSION) + output = view_builder.index(request, raw_group_types) + + self.assertIn('group_types', output) + for i in range(0, 10): + expected_group_type = dict( + name='new_type', + description='new_type_desc', + is_public=True, + group_specs={}, + id=42 + i + ) + self.assertDictMatch(expected_group_type, + output['group_types'][i]) diff --git a/cinder/tests/unit/fake_constants.py b/cinder/tests/unit/fake_constants.py index a16f6f05f..5bfea5b16 100644 --- a/cinder/tests/unit/fake_constants.py +++ b/cinder/tests/unit/fake_constants.py @@ -71,3 +71,4 @@ VOLUME_TYPE3_ID = 'a3d55d15-eeb1-4816-ada9-bf82decc09b3' VOLUME_TYPE4_ID = '69943076-754d-4da8-8718-0b0117e9cab1' VOLUME_TYPE5_ID = '1c450d81-8aab-459e-b338-a6569139b835' WILL_NOT_BE_FOUND_ID = 'ce816f65-c5aa-46d6-bd62-5272752d584a' +GROUP_TYPE_ID = '29514915-5208-46ab-9ece-1cc4688ad0c1' diff --git a/cinder/tests/unit/fake_group.py b/cinder/tests/unit/fake_group.py new file mode 100644 index 000000000..2bbc680f8 --- /dev/null +++ b/cinder/tests/unit/fake_group.py @@ -0,0 +1,49 @@ +# Copyright 2016 EMC Corporation +# +# 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_versionedobjects import fields + +from cinder import objects +from cinder.tests.unit import fake_constants as fake + + +def fake_db_group_type(**updates): + db_group_type = { + 'id': fake.GROUP_TYPE_ID, + 'name': 'type-1', + 'description': 'A fake group type', + 'is_public': True, + 'projects': [], + 'group_specs': {}, + } + + for name, field in objects.GroupType.fields.items(): + if name in db_group_type: + continue + if field.nullable: + db_group_type[name] = None + elif field.default != fields.UnspecifiedDefault: + db_group_type[name] = field.default + else: + raise Exception('fake_db_group_type needs help with %s.' % name) + + if updates: + db_group_type.update(updates) + + return db_group_type + + +def fake_group_type_obj(context, **updates): + return objects.GroupType._from_db_object( + context, objects.GroupType(), fake_db_group_type(**updates)) diff --git a/cinder/tests/unit/objects/test_group_type.py b/cinder/tests/unit/objects/test_group_type.py new file mode 100644 index 000000000..71c1877e9 --- /dev/null +++ b/cinder/tests/unit/objects/test_group_type.py @@ -0,0 +1,127 @@ +# Copyright 2016 EMC Corporation +# +# 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 +import six + +from cinder import objects +from cinder.tests.unit import fake_constants as fake +from cinder.tests.unit import fake_group +from cinder.tests.unit import objects as test_objects + + +class TestGroupType(test_objects.BaseObjectsTestCase): + + @mock.patch('cinder.db.sqlalchemy.api._group_type_get_full') + def test_get_by_id(self, group_type_get): + db_group_type = fake_group.fake_db_group_type() + group_type_get.return_value = db_group_type + group_type = objects.GroupType.get_by_id(self.context, + fake.GROUP_TYPE_ID) + self._compare(self, db_group_type, group_type) + + @mock.patch('cinder.volume.group_types.create') + def test_create(self, group_type_create): + db_group_type = fake_group.fake_db_group_type() + group_type_create.return_value = db_group_type + + group_type = objects.GroupType(context=self.context) + group_type.name = db_group_type['name'] + group_type.group_specs = db_group_type['group_specs'] + group_type.is_public = db_group_type['is_public'] + group_type.projects = db_group_type['projects'] + group_type.description = db_group_type['description'] + group_type.create() + + group_type_create.assert_called_once_with( + self.context, db_group_type['name'], + db_group_type['group_specs'], db_group_type['is_public'], + db_group_type['projects'], db_group_type['description']) + + @mock.patch('cinder.volume.group_types.update') + def test_save(self, group_type_update): + db_group_type = fake_group.fake_db_group_type() + group_type = objects.GroupType._from_db_object(self.context, + objects.GroupType(), + db_group_type) + group_type.description = 'foobar' + group_type.save() + group_type_update.assert_called_once_with(self.context, + group_type.id, + group_type.name, + group_type.description) + + @mock.patch('cinder.volume.group_types.destroy') + def test_destroy(self, group_type_destroy): + db_group_type = fake_group.fake_db_group_type() + group_type = objects.GroupType._from_db_object(self.context, + objects.GroupType(), + db_group_type) + group_type.destroy() + self.assertTrue(group_type_destroy.called) + admin_context = group_type_destroy.call_args[0][0] + self.assertTrue(admin_context.is_admin) + + @mock.patch('cinder.db.sqlalchemy.api._group_type_get_full') + def test_refresh(self, group_type_get): + db_type1 = fake_group.fake_db_group_type() + db_type2 = db_type1.copy() + db_type2['description'] = 'foobar' + + # updated description + group_type_get.side_effect = [db_type1, db_type2] + group_type = objects.GroupType.get_by_id(self.context, + fake.GROUP_TYPE_ID) + self._compare(self, db_type1, group_type) + + # description was updated, so a group type refresh should have a new + # value for that field + group_type.refresh() + self._compare(self, db_type2, group_type) + if six.PY3: + call_bool = mock.call.__bool__() + else: + call_bool = mock.call.__nonzero__() + group_type_get.assert_has_calls([mock.call(self.context, + fake.GROUP_TYPE_ID), + call_bool, + mock.call(self.context, + fake.GROUP_TYPE_ID)]) + + +class TestGroupTypeList(test_objects.BaseObjectsTestCase): + @mock.patch('cinder.volume.group_types.get_all_group_types') + def test_get_all(self, get_all_types): + db_group_type = fake_group.fake_db_group_type() + get_all_types.return_value = {db_group_type['name']: db_group_type} + + group_types = objects.GroupTypeList.get_all(self.context) + self.assertEqual(1, len(group_types)) + TestGroupType._compare(self, db_group_type, group_types[0]) + + @mock.patch('cinder.volume.group_types.get_all_group_types') + def test_get_all_with_pagination(self, get_all_types): + db_group_type = fake_group.fake_db_group_type() + get_all_types.return_value = {db_group_type['name']: db_group_type} + + group_types = objects.GroupTypeList.get_all(self.context, + filters={'is_public': + True}, + marker=None, + limit=1, + sort_keys='id', + sort_dirs='desc', + offset=None) + self.assertEqual(1, len(group_types)) + TestGroupType._compare(self, db_group_type, group_types[0]) diff --git a/cinder/tests/unit/objects/test_objects.py b/cinder/tests/unit/objects/test_objects.py index ccd20a5c5..3f595a8b9 100644 --- a/cinder/tests/unit/objects/test_objects.py +++ b/cinder/tests/unit/objects/test_objects.py @@ -46,6 +46,8 @@ object_data = { 'VolumeProperties': '1.0-42f00cf1f6c657377a3e2a7efbed0bca', 'VolumeType': '1.2-02ecb0baac87528d041f4ddd95b95579', 'VolumeTypeList': '1.1-15ecf022a68ddbb8c2a6739cfc9f8f5e', + 'GroupType': '1.0-d4a7b272199d0b0d6fc3ceed58539d30', + 'GroupTypeList': '1.0-1b54e51ad0fc1f3a8878f5010e7e16dc', } diff --git a/cinder/tests/unit/policy.json b/cinder/tests/unit/policy.json index dbe04c1b5..20c92fb20 100644 --- a/cinder/tests/unit/policy.json +++ b/cinder/tests/unit/policy.json @@ -113,6 +113,11 @@ "consistencygroup:get_cgsnapshot": "", "consistencygroup:get_all_cgsnapshots": "", + "group:group_types_manage": "rule:admin_api", + "group:group_types_specs": "rule:admin_api", + "group:access_group_types_specs": "rule:admin_api", + "group:group_type_access": "rule:admin_or_owner", + "scheduler_extension:scheduler_stats:get_pools" : "rule:admin_api", "message:delete": "rule:admin_or_owner", diff --git a/cinder/tests/unit/test_migrations.py b/cinder/tests/unit/test_migrations.py index 9a833471f..19ed4b7de 100644 --- a/cinder/tests/unit/test_migrations.py +++ b/cinder/tests/unit/test_migrations.py @@ -878,6 +878,69 @@ class MigrationsMixin(test_migrations.WalkVersionsMixin): self.assertIsInstance(columns.status.type, self.VARCHAR_TYPE) self.assertIsInstance(columns.service_id.type, self.INTEGER_TYPE) + def _check_077(self, engine, data): + """Test adding group types and specs tables.""" + self.assertTrue(engine.dialect.has_table(engine.connect(), + "group_types")) + group_types = db_utils.get_table(engine, 'group_types') + + self.assertIsInstance(group_types.c.id.type, + self.VARCHAR_TYPE) + self.assertIsInstance(group_types.c.name.type, + self.VARCHAR_TYPE) + self.assertIsInstance(group_types.c.description.type, + self.VARCHAR_TYPE) + self.assertIsInstance(group_types.c.created_at.type, + self.TIME_TYPE) + self.assertIsInstance(group_types.c.updated_at.type, + self.TIME_TYPE) + self.assertIsInstance(group_types.c.deleted_at.type, + self.TIME_TYPE) + self.assertIsInstance(group_types.c.deleted.type, + self.BOOL_TYPE) + self.assertIsInstance(group_types.c.is_public.type, + self.BOOL_TYPE) + + self.assertTrue(engine.dialect.has_table(engine.connect(), + "group_type_specs")) + group_specs = db_utils.get_table(engine, 'group_type_specs') + + self.assertIsInstance(group_specs.c.id.type, + self.INTEGER_TYPE) + self.assertIsInstance(group_specs.c.key.type, + self.VARCHAR_TYPE) + self.assertIsInstance(group_specs.c.value.type, + self.VARCHAR_TYPE) + self.assertIsInstance(group_specs.c.group_type_id.type, + self.VARCHAR_TYPE) + self.assertIsInstance(group_specs.c.created_at.type, + self.TIME_TYPE) + self.assertIsInstance(group_specs.c.updated_at.type, + self.TIME_TYPE) + self.assertIsInstance(group_specs.c.deleted_at.type, + self.TIME_TYPE) + self.assertIsInstance(group_specs.c.deleted.type, + self.BOOL_TYPE) + + self.assertTrue(engine.dialect.has_table(engine.connect(), + "group_type_projects")) + type_projects = db_utils.get_table(engine, 'group_type_projects') + + self.assertIsInstance(type_projects.c.id.type, + self.INTEGER_TYPE) + self.assertIsInstance(type_projects.c.created_at.type, + self.TIME_TYPE) + self.assertIsInstance(type_projects.c.updated_at.type, + self.TIME_TYPE) + self.assertIsInstance(type_projects.c.deleted_at.type, + self.TIME_TYPE) + self.assertIsInstance(type_projects.c.deleted.type, + self.BOOL_TYPE) + self.assertIsInstance(type_projects.c.group_type_id.type, + self.VARCHAR_TYPE) + self.assertIsInstance(type_projects.c.project_id.type, + self.VARCHAR_TYPE) + def test_walk_versions(self): self.walk_versions(False, False) diff --git a/cinder/volume/group_types.py b/cinder/volume/group_types.py new file mode 100644 index 000000000..1dd19a67c --- /dev/null +++ b/cinder/volume/group_types.py @@ -0,0 +1,180 @@ +# Copyright (c) 2016 EMC Corporation +# +# 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. + +"""Built-in group type properties.""" + + +from oslo_config import cfg +from oslo_db import exception as db_exc +from oslo_log import log as logging + +from cinder import context +from cinder import db +from cinder import exception +from cinder.i18n import _, _LE + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +def create(context, + name, + group_specs=None, + is_public=True, + projects=None, + description=None): + """Creates group types.""" + group_specs = group_specs or {} + projects = projects or [] + elevated = context if context.is_admin else context.elevated() + try: + type_ref = db.group_type_create(elevated, + dict(name=name, + group_specs=group_specs, + is_public=is_public, + description=description), + projects=projects) + except db_exc.DBError: + LOG.exception(_LE('DB error:')) + raise exception.GroupTypeCreateFailed(name=name, + group_specs=group_specs) + return type_ref + + +def update(context, id, name, description, is_public=None): + """Update group type by id.""" + if id is None: + msg = _("id cannot be None") + raise exception.InvalidGroupType(reason=msg) + elevated = context if context.is_admin else context.elevated() + try: + type_updated = db.group_type_update(elevated, + id, + dict(name=name, + description=description, + is_public=is_public)) + except db_exc.DBError: + LOG.exception(_LE('DB error:')) + raise exception.GroupTypeUpdateFailed(id=id) + return type_updated + + +def destroy(context, id): + """Marks group types as deleted.""" + if id is None: + msg = _("id cannot be None") + raise exception.InvalidGroupType(reason=msg) + else: + elevated = context if context.is_admin else context.elevated() + db.group_type_destroy(elevated, id) + + +def get_all_group_types(context, inactive=0, filters=None, marker=None, + limit=None, sort_keys=None, sort_dirs=None, + offset=None, list_result=False): + """Get all non-deleted group_types. + + Pass true as argument if you want deleted group types returned also. + + """ + grp_types = db.group_type_get_all(context, inactive, filters=filters, + marker=marker, limit=limit, + sort_keys=sort_keys, + sort_dirs=sort_dirs, offset=offset, + list_result=list_result) + return grp_types + + +def get_group_type(ctxt, id, expected_fields=None): + """Retrieves single group type by id.""" + if id is None: + msg = _("id cannot be None") + raise exception.InvalidGroupType(reason=msg) + + if ctxt is None: + ctxt = context.get_admin_context() + + return db.group_type_get(ctxt, id, expected_fields=expected_fields) + + +def get_group_type_by_name(context, name): + """Retrieves single group type by name.""" + if name is None: + msg = _("name cannot be None") + raise exception.InvalidGroupType(reason=msg) + + return db.group_type_get_by_name(context, name) + + +def get_default_group_type(): + """Get the default group type.""" + name = CONF.default_group_type + grp_type = {} + + if name is not None: + ctxt = context.get_admin_context() + try: + grp_type = get_group_type_by_name(ctxt, name) + except exception.GroupTypeNotFoundByName: + # Couldn't find group type with the name in default_group_type + # flag, record this issue and move on + LOG.exception(_LE('Default group type is not found. ' + 'Please check default_group_type config.')) + + return grp_type + + +def get_group_type_specs(group_type_id, key=False): + group_type = get_group_type(context.get_admin_context(), + group_type_id) + group_specs = group_type['group_specs'] + if key: + if group_specs.get(key): + return group_specs.get(key) + else: + return False + else: + return group_specs + + +def is_public_group_type(context, group_type_id): + """Return is_public boolean value of group type""" + group_type = db.group_type_get(context, group_type_id) + return group_type['is_public'] + + +def add_group_type_access(context, group_type_id, project_id): + """Add access to group type for project_id.""" + if group_type_id is None: + msg = _("group_type_id cannot be None") + raise exception.InvalidGroupType(reason=msg) + elevated = context if context.is_admin else context.elevated() + if is_public_group_type(elevated, group_type_id): + msg = _("Type access modification is not applicable to public group " + "type.") + raise exception.InvalidGroupType(reason=msg) + return db.group_type_access_add(elevated, group_type_id, project_id) + + +def remove_group_type_access(context, group_type_id, project_id): + """Remove access to group type for project_id.""" + if group_type_id is None: + msg = _("group_type_id cannot be None") + raise exception.InvalidGroupType(reason=msg) + elevated = context if context.is_admin else context.elevated() + if is_public_group_type(elevated, group_type_id): + msg = _("Type access modification is not applicable to public group " + "type.") + raise exception.InvalidGroupType(reason=msg) + return db.group_type_access_remove(elevated, group_type_id, project_id) diff --git a/etc/cinder/policy.json b/etc/cinder/policy.json index df6ea8078..69d638d07 100644 --- a/etc/cinder/policy.json +++ b/etc/cinder/policy.json @@ -109,6 +109,11 @@ "consistencygroup:get_cgsnapshot": "group:nobody", "consistencygroup:get_all_cgsnapshots": "group:nobody", + "group:group_types_manage": "rule:admin_api", + "group:group_types_specs": "rule:admin_api", + "group:access_group_types_specs": "rule:admin_api", + "group:group_type_access": "rule:admin_or_owner", + "scheduler_extension:scheduler_stats:get_pools" : "rule:admin_api", "message:delete": "rule:admin_or_owner", "message:get": "rule:admin_or_owner", diff --git a/releasenotes/notes/group-type-group-specs-531e33ee0ae9f822.yaml b/releasenotes/notes/group-type-group-specs-531e33ee0ae9f822.yaml new file mode 100644 index 000000000..601cddc71 --- /dev/null +++ b/releasenotes/notes/group-type-group-specs-531e33ee0ae9f822.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added group type and group specs APIs.