From 6c0f50b1ec933a61b84d806e748afd9cb74e5cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Gagne=CC=81?= Date: Wed, 25 Jun 2014 20:22:12 -0400 Subject: [PATCH] Volume type access extension This extension adds the ability to manage volume type access: * Volume types are public by default * Private volume types can be created by setting the is_public boolean field to False at creation time. * Access to a private volume type can be controlled by adding or removing a project from it. * Private volume types without projects are only visible by users with the admin role/context. Implementation details and unit tests were mostly adapted from Nova flavor access extension. DocImpact: New volume type access extension Implements: blueprint private-volume-types Change-Id: I8faf1d8097bf8412d4e169ec3503821351795561 --- cinder/api/contrib/types_manage.py | 4 +- cinder/api/contrib/volume_type_access.py | 215 ++++++++++++ cinder/api/extensions.py | 9 +- cinder/api/v1/types.py | 2 + cinder/api/v2/router.py | 3 +- cinder/api/v2/types.py | 43 ++- cinder/db/api.py | 53 ++- cinder/db/sqlalchemy/api.py | 145 +++++++-- .../versions/032_add_volume_type_projects.py | 74 +++++ .../versions/032_sqlite_downgrade.sql | 29 ++ cinder/db/sqlalchemy/models.py | 22 ++ cinder/exception.py | 10 + .../api/contrib/test_types_extra_specs.py | 2 +- cinder/tests/api/contrib/test_types_manage.py | 4 +- .../api/contrib/test_volume_type_access.py | 306 ++++++++++++++++++ cinder/tests/api/v1/test_types.py | 4 +- cinder/tests/api/v2/test_types.py | 4 +- cinder/tests/policy.json | 3 + cinder/tests/test_migrations.py | 50 +++ cinder/tests/test_volume_types.py | 18 ++ cinder/utils.py | 8 + cinder/volume/volume_types.py | 35 +- etc/cinder/policy.json | 3 + 23 files changed, 1000 insertions(+), 46 deletions(-) create mode 100644 cinder/api/contrib/volume_type_access.py create mode 100644 cinder/db/sqlalchemy/migrate_repo/versions/032_add_volume_type_projects.py create mode 100644 cinder/db/sqlalchemy/migrate_repo/versions/032_sqlite_downgrade.sql create mode 100644 cinder/tests/api/contrib/test_volume_type_access.py diff --git a/cinder/api/contrib/types_manage.py b/cinder/api/contrib/types_manage.py index bbe530269ca..96e47f95e8e 100644 --- a/cinder/api/contrib/types_manage.py +++ b/cinder/api/contrib/types_manage.py @@ -52,13 +52,15 @@ class VolumeTypesManageController(wsgi.Controller): vol_type = body['volume_type'] name = vol_type.get('name', None) specs = vol_type.get('extra_specs', {}) + is_public = vol_type.get('os-volume-type-access:is_public', True) if name is None or name == "": raise webob.exc.HTTPBadRequest() try: - volume_types.create(context, name, specs) + volume_types.create(context, name, specs, is_public) vol_type = volume_types.get_volume_type_by_name(context, name) + req.cache_resource(vol_type, name='types') notifier_info = dict(volume_types=vol_type) rpc.get_notifier('volumeType').info(context, 'volume_type.create', notifier_info) diff --git a/cinder/api/contrib/volume_type_access.py b/cinder/api/contrib/volume_type_access.py new file mode 100644 index 00000000000..5371316a88d --- /dev/null +++ b/cinder/api/contrib/volume_type_access.py @@ -0,0 +1,215 @@ +# +# 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 volume type access extension.""" + +import six +import webob + +from cinder.api import extensions +from cinder.api.openstack import wsgi +from cinder.api import xmlutil +from cinder import exception +from cinder.i18n import _ +from cinder.openstack.common import uuidutils +from cinder.volume import volume_types + + +soft_authorize = extensions.soft_extension_authorizer('volume', + 'volume_type_access') +authorize = extensions.extension_authorizer('volume', 'volume_type_access') + + +def make_volume_type(elem): + elem.set('{%s}is_public' % Volume_type_access.namespace, + '%s:is_public' % Volume_type_access.alias) + + +def make_volume_type_access(elem): + elem.set('volume_type_id') + elem.set('project_id') + + +class VolumeTypeTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('volume_type', selector='volume_type') + make_volume_type(root) + alias = Volume_type_access.alias + namespace = Volume_type_access.namespace + return xmlutil.SlaveTemplate(root, 1, nsmap={alias: namespace}) + + +class VolumeTypesTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('volume_types') + elem = xmlutil.SubTemplateElement( + root, 'volume_type', selector='volume_types') + make_volume_type(elem) + alias = Volume_type_access.alias + namespace = Volume_type_access.namespace + return xmlutil.SlaveTemplate(root, 1, nsmap={alias: namespace}) + + +class VolumeTypeAccessTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('volume_type_access') + elem = xmlutil.SubTemplateElement(root, 'access', + selector='volume_type_access') + make_volume_type_access(elem) + return xmlutil.MasterTemplate(root, 1) + + +def _marshall_volume_type_access(vol_type): + rval = [] + for project_id in vol_type['projects']: + rval.append({'volume_type_id': vol_type['id'], + 'project_id': project_id}) + + return {'volume_type_access': rval} + + +class VolumeTypeAccessController(object): + """The volume type access API controller for the OpenStack API.""" + + def __init__(self): + super(VolumeTypeAccessController, self).__init__() + + @wsgi.serializers(xml=VolumeTypeAccessTemplate) + def index(self, req, type_id): + context = req.environ['cinder.context'] + authorize(context) + + try: + vol_type = volume_types.get_volume_type( + context, type_id, expected_fields=['projects']) + except exception.VolumeTypeNotFound: + explanation = _("Volume type not found.") + raise webob.exc.HTTPNotFound(explanation=explanation) + + if vol_type['is_public']: + expl = _("Access list not available for public volume types.") + raise webob.exc.HTTPNotFound(explanation=expl) + + return _marshall_volume_type_access(vol_type) + + +class VolumeTypeActionController(wsgi.Controller): + """The volume type access API controller for the OpenStack API.""" + + def _check_body(self, body, action_name): + if not self.is_valid_body(body, action_name): + raise webob.exc.HTTPBadRequest() + access = body[action_name] + project = access.get('project') + if not uuidutils.is_uuid_like(project): + msg = _("Bad project format: " + "project is not in proper format (%s)") % project + raise webob.exc.HTTPBadRequest(explanation=msg) + + def _extend_vol_type(self, vol_type_rval, vol_type_ref): + key = "%s:is_public" % (Volume_type_access.alias) + vol_type_rval[key] = vol_type_ref['is_public'] + + @wsgi.extends + def show(self, req, resp_obj, id): + context = req.environ['cinder.context'] + if soft_authorize(context): + # Attach our slave template to the response object + resp_obj.attach(xml=VolumeTypeTemplate()) + vol_type = req.cached_resource_by_id(id, name='types') + self._extend_vol_type(resp_obj.obj['volume_type'], vol_type) + + @wsgi.extends + def index(self, req, resp_obj): + context = req.environ['cinder.context'] + if soft_authorize(context): + # Attach our slave template to the response object + resp_obj.attach(xml=VolumeTypesTemplate()) + for vol_type_rval in list(resp_obj.obj['volume_types']): + type_id = vol_type_rval['id'] + vol_type = req.cached_resource_by_id(type_id, name='types') + self._extend_vol_type(vol_type_rval, vol_type) + + @wsgi.extends + def detail(self, req, resp_obj): + context = req.environ['cinder.context'] + if soft_authorize(context): + # Attach our slave template to the response object + resp_obj.attach(xml=VolumeTypesTemplate()) + for vol_type_rval in list(resp_obj.obj['volume_types']): + type_id = vol_type_rval['id'] + vol_type = req.cached_resource_by_id(type_id, name='types') + self._extend_vol_type(vol_type_rval, vol_type) + + @wsgi.extends(action='create') + def create(self, req, body, resp_obj): + context = req.environ['cinder.context'] + if soft_authorize(context): + # Attach our slave template to the response object + resp_obj.attach(xml=VolumeTypeTemplate()) + type_id = resp_obj.obj['volume_type']['id'] + vol_type = req.cached_resource_by_id(type_id, name='types') + self._extend_vol_type(resp_obj.obj['volume_type'], vol_type) + + @wsgi.action('addProjectAccess') + def _addProjectAccess(self, req, id, body): + context = req.environ['cinder.context'] + authorize(context, action="addProjectAccess") + self._check_body(body, 'addProjectAccess') + project = body['addProjectAccess']['project'] + + try: + volume_types.add_volume_type_access(context, id, project) + except exception.VolumeTypeAccessExists as err: + raise webob.exc.HTTPConflict(explanation=six.text_type(err)) + except exception.VolumeTypeNotFound as err: + raise webob.exc.HTTPNotFound(explanation=six.text_type(err)) + return webob.Response(status_int=202) + + @wsgi.action('removeProjectAccess') + def _removeProjectAccess(self, req, id, body): + context = req.environ['cinder.context'] + authorize(context, action="removeProjectAccess") + self._check_body(body, 'removeProjectAccess') + project = body['removeProjectAccess']['project'] + + try: + volume_types.remove_volume_type_access(context, id, project) + except (exception.VolumeTypeNotFound, + exception.VolumeTypeAccessNotFound) as err: + raise webob.exc.HTTPNotFound(explanation=six.text_type(err)) + return webob.Response(status_int=202) + + +class Volume_type_access(extensions.ExtensionDescriptor): + """Volume type access support.""" + + name = "VolumeTypeAccess" + alias = "os-volume-type-access" + namespace = ("http://docs.openstack.org/volume/" + "ext/os-volume-type-access/api/v1") + updated = "2014-06-26T00:00:00Z" + + def get_resources(self): + resources = [] + res = extensions.ResourceExtension( + Volume_type_access.alias, + VolumeTypeAccessController(), + parent=dict(member_name='type', collection_name='types')) + resources.append(res) + return resources + + def get_controller_extensions(self): + controller = VolumeTypeActionController() + extension = extensions.ControllerExtension(self, 'types', controller) + return [extension] diff --git a/cinder/api/extensions.py b/cinder/api/extensions.py index 1f69793d6db..488f9409d1e 100644 --- a/cinder/api/extensions.py +++ b/cinder/api/extensions.py @@ -376,12 +376,15 @@ def load_standard_extensions(ext_mgr, logger, path, package, ext_list=None): def extension_authorizer(api_name, extension_name): - def authorize(context, target=None): + def authorize(context, target=None, action=None): if target is None: target = {'project_id': context.project_id, 'user_id': context.user_id} - action = '%s_extension:%s' % (api_name, extension_name) - cinder.policy.enforce(context, action, target) + if action is None: + act = '%s_extension:%s' % (api_name, extension_name) + else: + act = '%s_extension:%s:%s' % (api_name, extension_name, action) + cinder.policy.enforce(context, act, target) return authorize diff --git a/cinder/api/v1/types.py b/cinder/api/v1/types.py index 52fa4cae266..99f77c4c7c4 100644 --- a/cinder/api/v1/types.py +++ b/cinder/api/v1/types.py @@ -57,6 +57,7 @@ class VolumeTypesController(wsgi.Controller): """Returns the list of volume types.""" context = req.environ['cinder.context'] vol_types = volume_types.get_all_types(context).values() + req.cache_resource(vol_types, name='types') return self._view_builder.index(req, vol_types) @wsgi.serializers(xml=VolumeTypeTemplate) @@ -66,6 +67,7 @@ class VolumeTypesController(wsgi.Controller): try: vol_type = volume_types.get_volume_type(context, id) + req.cache_resource(vol_type, name='types') except exception.NotFound: raise exc.HTTPNotFound() diff --git a/cinder/api/v2/router.py b/cinder/api/v2/router.py index 44821c46599..db9a8b455d4 100644 --- a/cinder/api/v2/router.py +++ b/cinder/api/v2/router.py @@ -54,7 +54,8 @@ class APIRouter(cinder.api.openstack.APIRouter): self.resources['types'] = types.create_resource() mapper.resource("type", "types", - controller=self.resources['types']) + controller=self.resources['types'], + member={'action': 'POST'}) self.resources['snapshots'] = snapshots.create_resource(ext_mgr) mapper.resource("snapshot", "snapshots", diff --git a/cinder/api/v2/types.py b/cinder/api/v2/types.py index 75f8ee75a0d..c1b2dfc796e 100644 --- a/cinder/api/v2/types.py +++ b/cinder/api/v2/types.py @@ -15,6 +15,7 @@ """The volume type & volume types extra specs extension.""" +from oslo.utils import strutils from webob import exc from cinder.api.openstack import wsgi @@ -22,6 +23,7 @@ from cinder.api.views import types as views_types from cinder.api import xmlutil from cinder import exception from cinder.i18n import _ +from cinder import utils from cinder.volume import volume_types @@ -56,9 +58,9 @@ class VolumeTypesController(wsgi.Controller): @wsgi.serializers(xml=VolumeTypesTemplate) def index(self, req): """Returns the list of volume types.""" - context = req.environ['cinder.context'] - vol_types = volume_types.get_all_types(context).values() - return self._view_builder.index(req, vol_types) + limited_types = self._get_volume_types(req) + req.cache_resource(limited_types, name='types') + return self._view_builder.index(req, limited_types) @wsgi.serializers(xml=VolumeTypeTemplate) def show(self, req, id): @@ -67,12 +69,47 @@ class VolumeTypesController(wsgi.Controller): try: vol_type = volume_types.get_volume_type(context, id) + req.cache_resource(vol_type, name='types') except exception.NotFound: msg = _("Volume type not found") raise exc.HTTPNotFound(explanation=msg) return self._view_builder.show(req, vol_type) + def _parse_is_public(self, is_public): + """Parse is_public into something usable. + + * True: List public volume types only + * False: List private volume types only + * None: List both public and private volume 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_volume_types(self, req): + """Helper function that returns a list of type dicts.""" + filters = {} + context = req.environ['cinder.context'] + if context.is_admin: + # Only admin has query access to all volume types + filters['is_public'] = self._parse_is_public( + req.params.get('is_public', None)) + else: + filters['is_public'] = True + limited_types = volume_types.get_all_types( + context, search_opts=filters).values() + return limited_types + def create_resource(): return wsgi.Resource(VolumeTypesController()) diff --git a/cinder/db/api.py b/cinder/db/api.py index 03450471765..36c66b8316f 100644 --- a/cinder/db/api.py +++ b/cinder/db/api.py @@ -369,19 +369,41 @@ def volume_admin_metadata_update(context, volume_id, metadata, delete): ################## -def volume_type_create(context, values): +def volume_type_create(context, values, projects=None): """Create a new volume type.""" - return IMPL.volume_type_create(context, values) + return IMPL.volume_type_create(context, values, projects) -def volume_type_get_all(context, inactive=False): - """Get all volume types.""" - return IMPL.volume_type_get_all(context, inactive) +def volume_type_get_all(context, inactive=False, filters=None): + """Get all volume types. + + :param context: context to query under + :param inactive: Include inactive volume types to the result set + :param filters: Filters for the query in the form of key/value. + + :is_public: Filter volume types based on visibility: + + * **True**: List public volume types only + * **False**: List private volume types only + * **None**: List both public and private volume types + + :returns: list of matching volume types + """ + + return IMPL.volume_type_get_all(context, inactive, filters) -def volume_type_get(context, id, inactive=False): - """Get volume type by id.""" - return IMPL.volume_type_get(context, id, inactive) +def volume_type_get(context, id, inactive=False, expected_fields=None): + """Get volume type by id. + + :param context: context to query under + :param id: Volume type id to get. + :param inactive: Consider inactive volume types when searching + :param expected_fields: Return those additional fields. + Supported fields are: projects. + :returns: volume type + """ + return IMPL.volume_type_get(context, id, inactive, expected_fields) def volume_type_get_by_name(context, name): @@ -435,6 +457,21 @@ def volume_get_active_by_window(context, begin, end=None, project_id=None): return IMPL.volume_get_active_by_window(context, begin, end, project_id) +def volume_type_access_get_all(context, type_id): + """Get all volume type access of a volume type.""" + return IMPL.volume_type_access_get_all(context, type_id) + + +def volume_type_access_add(context, type_id, project_id): + """Add volume type access for project.""" + return IMPL.volume_type_access_add(context, type_id, project_id) + + +def volume_type_access_remove(context, type_id, project_id): + """Remove volume type access for project.""" + return IMPL.volume_type_access_remove(context, type_id, project_id) + + #################### diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index 7def024a623..b1dea38c03b 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -37,6 +37,7 @@ from sqlalchemy import or_ from sqlalchemy.orm import joinedload, joinedload_all from sqlalchemy.orm import RelationshipProperty from sqlalchemy.sql.expression import literal_column +from sqlalchemy.sql.expression import true from sqlalchemy.sql import func from cinder.common import sqlalchemyutils @@ -1871,8 +1872,8 @@ def snapshot_metadata_update(context, snapshot_id, metadata, delete): @require_admin_context -def volume_type_create(context, values): - """Create a new instance type. +def volume_type_create(context, values, projects=None): + """Create a new volume type. In order to pass in extra specs, the values dict should contain a 'extra_specs' key/value pair: @@ -1881,6 +1882,8 @@ def volume_type_create(context, values): if not values.get('id'): values['id'] = str(uuid.uuid4()) + projects = projects or [] + session = get_session() with session.begin(): try: @@ -1901,20 +1904,59 @@ def volume_type_create(context, values): session.add(volume_type_ref) except Exception as e: raise db_exc.DBError(e) + 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) return volume_type_ref +def _volume_type_get_query(context, session=None, read_deleted=None, + expected_fields=None): + expected_fields = expected_fields or [] + query = model_query(context, + models.VolumeTypes, + session=session, + read_deleted=read_deleted).\ + options(joinedload('extra_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 + + @require_context def volume_type_get_all(context, inactive=False, filters=None): """Returns a dict describing all volume_types with name as key.""" filters = filters or {} read_deleted = "yes" if inactive else "no" - rows = model_query(context, models.VolumeTypes, - read_deleted=read_deleted).\ - options(joinedload('extra_specs')).\ - order_by("name").\ - all() + + query = _volume_type_get_query(context, read_deleted=read_deleted) + + if 'is_public' in filters and filters['is_public'] is not None: + 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.extend([ + projects_attr.any(project_id=context.project_id, deleted=False) + ]) + if len(the_filter) > 1: + query = query.filter(or_(*the_filter)) + else: + query = query.filter(the_filter[0]) + + rows = query.order_by("name").all() result = {} for row in rows: @@ -1923,28 +1965,50 @@ def volume_type_get_all(context, inactive=False, filters=None): 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", + session=session, base_model=models.VolumeTypes).\ + 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() + if not result: + raise exception.VolumeTypeNotFound(volume_type_id=id) + return result[0] + + @require_context -def _volume_type_get(context, id, session=None, inactive=False): +def _volume_type_get(context, id, session=None, inactive=False, + expected_fields=None): + expected_fields = expected_fields or [] read_deleted = "yes" if inactive else "no" - result = model_query(context, - models.VolumeTypes, - session=session, - read_deleted=read_deleted).\ - options(joinedload('extra_specs')).\ + result = _volume_type_get_query( + context, session, read_deleted, expected_fields).\ filter_by(id=id).\ first() if not result: raise exception.VolumeTypeNotFound(volume_type_id=id) - return _dict_with_extra_specs(result) + vtype = _dict_with_extra_specs(result) + + if 'projects' in expected_fields: + vtype['projects'] = [p['project_id'] for p in result['projects']] + + return vtype @require_context -def volume_type_get(context, id, inactive=False): +def volume_type_get(context, id, inactive=False, expected_fields=None): """Return a dict describing specific volume_type.""" - return _volume_type_get(context, id, None, inactive) + return _volume_type_get(context, id, + session=None, + inactive=inactive, + expected_fields=expected_fields) @require_context @@ -1956,8 +2020,8 @@ def _volume_type_get_by_name(context, name, session=None): if not result: raise exception.VolumeTypeNotFoundByName(volume_type_name=name) - else: - return _dict_with_extra_specs(result) + + return _dict_with_extra_specs(result) @require_context @@ -2107,6 +2171,51 @@ def volume_get_active_by_window(context, return query.all() +def _volume_type_access_query(context, session=None): + return model_query(context, models.VolumeTypeProjects, session=session, + read_deleted="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) + return _volume_type_access_query(context).\ + filter_by(volume_type_id=volume_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.""" + volume_type_id = _volume_type_get_id_from_volume_type(context, type_id) + + access_ref = models.VolumeTypeProjects() + access_ref.update({"volume_type_id": volume_type_id, + "project_id": project_id}) + + session = get_session() + with session.begin(): + try: + access_ref.save(session=session) + except db_exc.DBDuplicateEntry: + raise exception.VolumeTypeAccessExists(volume_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.""" + volume_type_id = _volume_type_get_id_from_volume_type(context, type_id) + + count = _volume_type_access_query(context).\ + filter_by(volume_type_id=volume_type_id).\ + filter_by(project_id=project_id).\ + soft_delete(synchronize_session=False) + if count == 0: + raise exception.VolumeTypeAccessNotFound( + volume_type_id=type_id, project_id=project_id) + + #################### diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/032_add_volume_type_projects.py b/cinder/db/sqlalchemy/migrate_repo/versions/032_add_volume_type_projects.py new file mode 100644 index 00000000000..693e4a76c3b --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/032_add_volume_type_projects.py @@ -0,0 +1,74 @@ +# 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, UniqueConstraint +from sqlalchemy import Integer, MetaData, String, Table, ForeignKey + +from cinder.i18n import _ +from cinder.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + volume_types = Table('volume_types', meta, autoload=True) + is_public = Column('is_public', Boolean) + + try: + volume_types.create_column(is_public) + # pylint: disable=E1120 + volume_types.update().values(is_public=True).execute() + except Exception: + LOG.error(_("Column |%s| not created!"), repr(is_public)) + raise + + volume_type_projects = Table( + 'volume_type_projects', meta, + Column('id', Integer, primary_key=True, nullable=False), + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('deleted_at', DateTime), + Column('volume_type_id', String(36), + ForeignKey('volume_types.id')), + Column('project_id', String(length=255)), + Column('deleted', Boolean(create_constraint=True, name=None)), + UniqueConstraint('volume_type_id', 'project_id', 'deleted'), + mysql_engine='InnoDB', + ) + + try: + volume_type_projects.create() + except Exception: + LOG.error(_("Table |%s| not created!"), repr(volume_type_projects)) + raise + + +def downgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + volume_types = Table('volume_types', meta, autoload=True) + is_public = volume_types.columns.is_public + try: + volume_types.drop_column(is_public) + except Exception: + LOG.error(_("volume_types.is_public column not dropped")) + raise + + volume_type_projects = Table('volume_type_projects', meta, autoload=True) + try: + volume_type_projects.drop() + except Exception: + LOG.error(_("volume_type_projects table not dropped")) + raise diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/032_sqlite_downgrade.sql b/cinder/db/sqlalchemy/migrate_repo/versions/032_sqlite_downgrade.sql new file mode 100644 index 00000000000..ade3dc20578 --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/032_sqlite_downgrade.sql @@ -0,0 +1,29 @@ +-- As sqlite does not support the DROP CHECK, we need to create +-- the table, and move all the data to it. + +CREATE TABLE volume_types_v31 ( + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME, + deleted BOOLEAN, + id VARCHAR(36) NOT NULL, + name VARCHAR(255), + qos_specs_id VARCHAR(36), + PRIMARY KEY (id), + CHECK (deleted IN (0, 1)), + FOREIGN KEY(qos_specs_id) REFERENCES quality_of_service_specs (id) +); + +INSERT INTO volume_types_v31 + SELECT created_at, + updated_at, + deleted_at, + deleted, + id, + name, + qos_specs_id + FROM volume_types; + +DROP TABLE volume_types; +ALTER TABLE volume_types_v31 RENAME TO volume_types; +DROP TABLE volume_type_projects; diff --git a/cinder/db/sqlalchemy/models.py b/cinder/db/sqlalchemy/models.py index 2c5b8d079f5..7acb4cacfc4 100644 --- a/cinder/db/sqlalchemy/models.py +++ b/cinder/db/sqlalchemy/models.py @@ -203,6 +203,7 @@ class VolumeTypes(BASE, CinderBase): # A reference to qos_specs entity qos_specs_id = Column(String(36), ForeignKey('quality_of_service_specs.id')) + is_public = Column(Boolean, default=True) volumes = relationship(Volume, backref=backref('volume_type', uselist=False), foreign_keys=id, @@ -211,6 +212,27 @@ class VolumeTypes(BASE, CinderBase): 'VolumeTypes.deleted == False)') +class VolumeTypeProjects(BASE, CinderBase): + """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"), + ) + id = Column(Integer, primary_key=True) + volume_type_id = Column(Integer, ForeignKey('volume_types.id'), + nullable=False) + project_id = Column(String(255)) + + volume_type = relationship( + VolumeTypes, + backref="projects", + foreign_keys=volume_type_id, + primaryjoin='and_(' + 'VolumeTypeProjects.volume_type_id == VolumeTypes.id,' + 'VolumeTypeProjects.deleted == False)') + + class VolumeTypeExtraSpecs(BASE, CinderBase): """Represents additional specs as key/value pairs for a volume_type.""" __tablename__ = 'volume_type_extra_specs' diff --git a/cinder/exception.py b/cinder/exception.py index 10f6913cef2..5edad3ff835 100755 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -271,6 +271,11 @@ class VolumeTypeNotFoundByName(VolumeTypeNotFound): "could not be found.") +class VolumeTypeAccessNotFound(NotFound): + message = _("Volume type access not found for %(volume_type_id)s / " + "%(project_id)s combination.") + + class VolumeTypeExtraSpecsNotFound(NotFound): message = _("Volume Type %(volume_type_id)s has no extra specs with " "key %(extra_specs_key)s.") @@ -376,6 +381,11 @@ class VolumeTypeExists(Duplicate): message = _("Volume Type %(id)s already exists.") +class VolumeTypeAccessExists(Duplicate): + message = _("Volume type access for %(volume_type_id)s / " + "%(project_id)s combination already exists.") + + class VolumeTypeEncryptionExists(Invalid): message = _("Volume type encryption for type %(type_id)s already exists.") diff --git a/cinder/tests/api/contrib/test_types_extra_specs.py b/cinder/tests/api/contrib/test_types_extra_specs.py index f1a7cae7e3e..d8653148550 100644 --- a/cinder/tests/api/contrib/test_types_extra_specs.py +++ b/cinder/tests/api/contrib/test_types_extra_specs.py @@ -57,7 +57,7 @@ def stub_volume_type_extra_specs(): return specs -def volume_type_get(context, volume_type_id): +def volume_type_get(context, id, inactive=False, expected_fields=None): pass diff --git a/cinder/tests/api/contrib/test_types_manage.py b/cinder/tests/api/contrib/test_types_manage.py index ccca201d16e..2fd4de350a1 100644 --- a/cinder/tests/api/contrib/test_types_manage.py +++ b/cinder/tests/api/contrib/test_types_manage.py @@ -50,11 +50,11 @@ def return_volume_types_with_volumes_destroy(context, id): pass -def return_volume_types_create(context, name, specs): +def return_volume_types_create(context, name, specs, is_public): pass -def return_volume_types_create_duplicate_type(context, name, specs): +def return_volume_types_create_duplicate_type(context, name, specs, is_public): raise exception.VolumeTypeExists(id=name) diff --git a/cinder/tests/api/contrib/test_volume_type_access.py b/cinder/tests/api/contrib/test_volume_type_access.py new file mode 100644 index 00000000000..9bb62693ca2 --- /dev/null +++ b/cinder/tests/api/contrib/test_volume_type_access.py @@ -0,0 +1,306 @@ +# +# 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 datetime + +import webob + +from cinder.api.contrib import volume_type_access as type_access +from cinder.api.v2 import types as types_api_v2 +from cinder import context +from cinder import db +from cinder import exception +from cinder import test +from cinder.tests.api import fakes + + +def generate_type(type_id, is_public): + return { + 'id': type_id, + 'name': u'test', + 'deleted': False, + 'created_at': datetime.datetime(2012, 1, 1, 1, 1, 1, 1), + 'updated_at': None, + 'deleted_at': None, + 'is_public': bool(is_public) + } + + +VOLUME_TYPES = { + '0': generate_type('0', True), + '1': generate_type('1', True), + '2': generate_type('2', False), + '3': generate_type('3', False)} + +PROJ1_UUID = '11111111-1111-1111-1111-111111111111' +PROJ2_UUID = '22222222-2222-2222-2222-222222222222' +PROJ3_UUID = '33333333-3333-3333-3333-333333333333' + +ACCESS_LIST = [{'volume_type_id': '2', 'project_id': PROJ2_UUID}, + {'volume_type_id': '2', 'project_id': PROJ3_UUID}, + {'volume_type_id': '3', 'project_id': PROJ3_UUID}] + + +def fake_volume_type_get(context, id, inactive=False, expected_fields=None): + vol = VOLUME_TYPES[id] + if expected_fields and 'projects' in expected_fields: + vol['projects'] = [a['project_id'] + for a in ACCESS_LIST if a['volume_type_id'] == id] + return vol + + +def _has_type_access(type_id, project_id): + for access in ACCESS_LIST: + if access['volume_type_id'] == type_id and \ + access['project_id'] == project_id: + return True + return False + + +def fake_volume_type_get_all(context, inactive=False, filters=None): + if filters is None or filters['is_public'] is None: + return VOLUME_TYPES + res = {} + for k, v in VOLUME_TYPES.iteritems(): + if filters['is_public'] and _has_type_access(k, context.project_id): + res.update({k: v}) + continue + if v['is_public'] == filters['is_public']: + res.update({k: v}) + return res + + +class FakeResponse(object): + obj = {'volume_type': {'id': '0'}, + 'volume_types': [ + {'id': '0'}, + {'id': '2'}]} + + def attach(self, **kwargs): + pass + + +class FakeRequest(object): + environ = {"cinder.context": context.get_admin_context()} + + def cached_resource_by_id(self, resource_id, name=None): + return VOLUME_TYPES[resource_id] + + +class VolumeTypeAccessTest(test.TestCase): + + def setUp(self): + super(VolumeTypeAccessTest, self).setUp() + self.type_controller_v2 = types_api_v2.VolumeTypesController() + self.type_access_controller = type_access.VolumeTypeAccessController() + self.type_action_controller = type_access.VolumeTypeActionController() + self.req = FakeRequest() + self.context = self.req.environ['cinder.context'] + self.stubs.Set(db, 'volume_type_get', + fake_volume_type_get) + self.stubs.Set(db, 'volume_type_get_all', + fake_volume_type_get_all) + + def assertVolumeTypeListEqual(self, expected, observed): + self.assertEqual(len(expected), len(observed)) + expected = sorted(expected, key=lambda item: item['id']) + observed = sorted(observed, key=lambda item: item['id']) + for d1, d2 in zip(expected, observed): + self.assertEqual(d1['id'], d2['id']) + + def test_list_type_access_public(self): + """Querying os-volume-type-access on public type should return 404.""" + req = fakes.HTTPRequest.blank('/v2/fake/types/os-volume-type-access', + use_admin_context=True) + self.assertRaises(webob.exc.HTTPNotFound, + self.type_access_controller.index, + req, '1') + + def test_list_type_access_private(self): + expected = {'volume_type_access': [ + {'volume_type_id': '2', 'project_id': PROJ2_UUID}, + {'volume_type_id': '2', 'project_id': PROJ3_UUID}]} + result = self.type_access_controller.index(self.req, '2') + self.assertEqual(expected, result) + + def test_list_with_no_context(self): + req = fakes.HTTPRequest.blank('/v2/flavors/fake/flavors') + + def fake_authorize(context, target=None, action=None): + raise exception.PolicyNotAuthorized(action='index') + self.stubs.Set(type_access, 'authorize', fake_authorize) + + self.assertRaises(exception.PolicyNotAuthorized, + self.type_access_controller.index, + req, 'fake') + + def test_list_type_with_admin_default_proj1(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types', + use_admin_context=True) + req.environ['cinder.context'].project_id = PROJ1_UUID + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_admin_default_proj2(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}, {'id': '2'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types', + use_admin_context=True) + req.environ['cinder.context'].project_id = PROJ2_UUID + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_admin_ispublic_true(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=true', + use_admin_context=True) + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_admin_ispublic_false(self): + expected = {'volume_types': [{'id': '2'}, {'id': '3'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=false', + use_admin_context=True) + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_admin_ispublic_false_proj2(self): + expected = {'volume_types': [{'id': '2'}, {'id': '3'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=false', + use_admin_context=True) + req.environ['cinder.context'].project_id = PROJ2_UUID + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_admin_ispublic_none(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}, {'id': '2'}, + {'id': '3'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=none', + use_admin_context=True) + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_no_admin_default(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types', + use_admin_context=False) + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_no_admin_ispublic_true(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=true', + use_admin_context=False) + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_no_admin_ispublic_false(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=false', + use_admin_context=False) + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_list_type_with_no_admin_ispublic_none(self): + expected = {'volume_types': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=none', + use_admin_context=False) + result = self.type_controller_v2.index(req) + self.assertVolumeTypeListEqual(expected['volume_types'], + result['volume_types']) + + def test_show(self): + resp = FakeResponse() + self.type_action_controller.show(self.req, resp, '0') + self.assertEqual({'id': '0', 'os-volume-type-access:is_public': True}, + resp.obj['volume_type']) + self.type_action_controller.show(self.req, resp, '2') + self.assertEqual({'id': '0', 'os-volume-type-access:is_public': False}, + resp.obj['volume_type']) + + def test_detail(self): + resp = FakeResponse() + self.type_action_controller.detail(self.req, resp) + self.assertEqual( + [{'id': '0', 'os-volume-type-access:is_public': True}, + {'id': '2', 'os-volume-type-access:is_public': False}], + resp.obj['volume_types']) + + def test_create(self): + resp = FakeResponse() + self.type_action_controller.create(self.req, {}, resp) + self.assertEqual({'id': '0', 'os-volume-type-access:is_public': True}, + resp.obj['volume_type']) + + def test_add_project_access(self): + def stub_add_volume_type_access(context, type_id, project_id): + self.assertEqual('3', type_id, "type_id") + self.assertEqual(PROJ2_UUID, project_id, "project_id") + self.stubs.Set(db, 'volume_type_access_add', + stub_add_volume_type_access) + body = {'addProjectAccess': {'project': PROJ2_UUID}} + req = fakes.HTTPRequest.blank('/v2/fake/types/2/action', + use_admin_context=True) + result = self.type_action_controller._addProjectAccess(req, '3', body) + self.assertEqual(202, result.status_code) + + def test_add_project_access_with_no_admin_user(self): + req = fakes.HTTPRequest.blank('/v2/fake/types/2/action', + use_admin_context=False) + body = {'addProjectAccess': {'project': PROJ2_UUID}} + self.assertRaises(exception.PolicyNotAuthorized, + self.type_action_controller._addProjectAccess, + req, '2', body) + + def test_add_project_access_with_already_added_access(self): + def stub_add_volume_type_access(context, type_id, project_id): + raise exception.VolumeTypeAccessExists(volume_type_id=type_id, + project_id=project_id) + self.stubs.Set(db, 'volume_type_access_add', + stub_add_volume_type_access) + body = {'addProjectAccess': {'project': PROJ2_UUID}} + req = fakes.HTTPRequest.blank('/v2/fake/types/2/action', + use_admin_context=True) + self.assertRaises(webob.exc.HTTPConflict, + self.type_action_controller._addProjectAccess, + req, '3', body) + + def test_remove_project_access_with_bad_access(self): + def stub_remove_volume_type_access(context, type_id, project_id): + raise exception.VolumeTypeAccessNotFound(volume_type_id=type_id, + project_id=project_id) + self.stubs.Set(db, 'volume_type_access_remove', + stub_remove_volume_type_access) + body = {'removeProjectAccess': {'project': PROJ2_UUID}} + req = fakes.HTTPRequest.blank('/v2/fake/types/2/action', + use_admin_context=True) + self.assertRaises(webob.exc.HTTPNotFound, + self.type_action_controller._removeProjectAccess, + req, '3', body) + + def test_remove_project_access_with_no_admin_user(self): + req = fakes.HTTPRequest.blank('/v2/fake/types/2/action', + use_admin_context=False) + body = {'removeProjectAccess': {'project': PROJ2_UUID}} + self.assertRaises(exception.PolicyNotAuthorized, + self.type_action_controller._removeProjectAccess, + req, '2', body) diff --git a/cinder/tests/api/v1/test_types.py b/cinder/tests/api/v1/test_types.py index 844ac9c37d6..7b8b873cbb3 100644 --- a/cinder/tests/api/v1/test_types.py +++ b/cinder/tests/api/v1/test_types.py @@ -36,13 +36,13 @@ def stub_volume_type(id): return dict(id=id, name='vol_type_%s' % str(id), extra_specs=specs) -def return_volume_types_get_all_types(context): +def return_volume_types_get_all_types(context, search_opts=None): return dict(vol_type_1=stub_volume_type(1), vol_type_2=stub_volume_type(2), vol_type_3=stub_volume_type(3)) -def return_empty_volume_types_get_all_types(context): +def return_empty_volume_types_get_all_types(context, search_opts=None): return {} diff --git a/cinder/tests/api/v2/test_types.py b/cinder/tests/api/v2/test_types.py index 6d56262a394..8b120a6c37f 100644 --- a/cinder/tests/api/v2/test_types.py +++ b/cinder/tests/api/v2/test_types.py @@ -42,7 +42,7 @@ def stub_volume_type(id): ) -def return_volume_types_get_all_types(context): +def return_volume_types_get_all_types(context, search_opts=None): return dict( vol_type_1=stub_volume_type(1), vol_type_2=stub_volume_type(2), @@ -50,7 +50,7 @@ def return_volume_types_get_all_types(context): ) -def return_empty_volume_types_get_all_types(context): +def return_empty_volume_types_get_all_types(context, search_opts=None): return {} diff --git a/cinder/tests/policy.json b/cinder/tests/policy.json index 10b7a518362..75cc24d7534 100644 --- a/cinder/tests/policy.json +++ b/cinder/tests/policy.json @@ -45,6 +45,9 @@ "volume_extension:volume_actions:upload_image": "", "volume_extension:types_manage": "", "volume_extension:types_extra_specs": "", + "volume_extension:volume_type_access": "", + "volume_extension:volume_type_access:addProjectAccess": "rule:admin_api", + "volume_extension:volume_type_access:removeProjectAccess": "rule:admin_api", "volume_extension:volume_type_encryption": "rule:admin_api", "volume_extension:volume_encryption_metadata": "rule:admin_or_owner", "volume_extension:qos_specs_manage": "", diff --git a/cinder/tests/test_migrations.py b/cinder/tests/test_migrations.py index fef77e34fe1..11890439f99 100644 --- a/cinder/tests/test_migrations.py +++ b/cinder/tests/test_migrations.py @@ -1298,3 +1298,53 @@ class TestMigrations(test.TestCase): execute().scalar() self.assertEqual(4, num_defaults) + + def test_migration_032(self): + """Test adding volume_type_projects table works correctly.""" + for (key, engine) in self.engines.items(): + migration_api.version_control(engine, + TestMigrations.REPOSITORY, + migration.db_initial_version()) + migration_api.upgrade(engine, TestMigrations.REPOSITORY, 31) + metadata = sqlalchemy.schema.MetaData() + metadata.bind = engine + + migration_api.upgrade(engine, TestMigrations.REPOSITORY, 32) + + self.assertTrue(engine.dialect.has_table(engine.connect(), + "volume_type_projects")) + + volume_type_projects = sqlalchemy.Table('volume_type_projects', + metadata, + autoload=True) + self.assertIsInstance(volume_type_projects.c.created_at.type, + self.time_type[engine.name]) + self.assertIsInstance(volume_type_projects.c.updated_at.type, + self.time_type[engine.name]) + self.assertIsInstance(volume_type_projects.c.deleted_at.type, + self.time_type[engine.name]) + self.assertIsInstance(volume_type_projects.c.deleted.type, + self.bool_type[engine.name]) + self.assertIsInstance(volume_type_projects.c.id.type, + sqlalchemy.types.INTEGER) + self.assertIsInstance(volume_type_projects.c.volume_type_id.type, + sqlalchemy.types.VARCHAR) + self.assertIsInstance(volume_type_projects.c.project_id.type, + sqlalchemy.types.VARCHAR) + + volume_types = sqlalchemy.Table('volume_types', + metadata, + autoload=True) + self.assertIsInstance(volume_types.c.is_public.type, + self.bool_type[engine.name]) + + migration_api.downgrade(engine, TestMigrations.REPOSITORY, 31) + metadata = sqlalchemy.schema.MetaData() + metadata.bind = engine + + self.assertFalse(engine.dialect.has_table(engine.connect(), + "volume_type_projects")) + volume_types = sqlalchemy.Table('volume_types', + metadata, + autoload=True) + self.assertNotIn('is_public', volume_types.c) diff --git a/cinder/tests/test_volume_types.py b/cinder/tests/test_volume_types.py index 9e962f83698..59562acacba 100644 --- a/cinder/tests/test_volume_types.py +++ b/cinder/tests/test_volume_types.py @@ -229,6 +229,24 @@ class VolumeTypeTestCase(test.TestCase): encryption) self.assertTrue(volume_types.is_encrypted(self.ctxt, volume_type_id)) + def test_add_access(self): + project_id = '456' + vtype = volume_types.create(self.ctxt, 'type1') + vtype_id = vtype.get('id') + + volume_types.add_volume_type_access(self.ctxt, vtype_id, project_id) + vtype_access = db.volume_type_access_get_all(self.ctxt, vtype_id) + self.assertIn(project_id, [a.project_id for a in vtype_access]) + + def test_remove_access(self): + project_id = '456' + vtype = volume_types.create(self.ctxt, 'type1', projects=['456']) + vtype_id = vtype.get('id') + + volume_types.remove_volume_type_access(self.ctxt, vtype_id, project_id) + vtype_access = db.volume_type_access_get_all(self.ctxt, vtype_id) + self.assertNotIn(project_id, vtype_access) + def test_get_volume_type_qos_specs(self): qos_ref = qos_specs.create(self.ctxt, 'qos-specs-1', {'k1': 'v1', 'k2': 'v2', diff --git a/cinder/utils.py b/cinder/utils.py index eca0d912114..cf912f9f828 100644 --- a/cinder/utils.py +++ b/cinder/utils.py @@ -414,6 +414,14 @@ def is_valid_boolstr(val): val == '1' or val == '0') +def is_none_string(val): + """Check if a string represents a None value.""" + if not isinstance(val, six.string_types): + return False + + return val.lower() == 'none' + + def monkey_patch(): """If the CONF.monkey_patch set as True, this function patches a decorator diff --git a/cinder/volume/volume_types.py b/cinder/volume/volume_types.py index d2841aae014..72c4e965c70 100644 --- a/cinder/volume/volume_types.py +++ b/cinder/volume/volume_types.py @@ -34,13 +34,16 @@ CONF = cfg.CONF LOG = logging.getLogger(__name__) -def create(context, name, extra_specs=None): +def create(context, name, extra_specs=None, is_public=True, projects=None): """Creates volume types.""" extra_specs = extra_specs or {} + projects = projects or [] try: type_ref = db.volume_type_create(context, dict(name=name, - extra_specs=extra_specs)) + extra_specs=extra_specs, + is_public=is_public), + projects=projects) except db_exc.DBError as e: LOG.exception(_LE('DB error: %s') % e) raise exception.VolumeTypeCreateFailed(name=name, @@ -64,7 +67,13 @@ def get_all_types(context, inactive=0, search_opts=None): """ search_opts = search_opts or {} - vol_types = db.volume_type_get_all(context, inactive) + filters = {} + + if 'is_public' in search_opts: + filters['is_public'] = search_opts['is_public'] + del search_opts['is_public'] + + vol_types = db.volume_type_get_all(context, inactive, filters=filters) if search_opts: LOG.debug("Searching by: %s" % search_opts) @@ -96,7 +105,7 @@ def get_all_types(context, inactive=0, search_opts=None): return vol_types -def get_volume_type(ctxt, id): +def get_volume_type(ctxt, id, expected_fields=None): """Retrieves single volume type by id.""" if id is None: msg = _("id cannot be None") @@ -105,7 +114,7 @@ def get_volume_type(ctxt, id): if ctxt is None: ctxt = context.get_admin_context() - return db.volume_type_get(ctxt, id) + return db.volume_type_get(ctxt, id, expected_fields=expected_fields) def get_volume_type_by_name(context, name): @@ -149,6 +158,22 @@ def get_volume_type_extra_specs(volume_type_id, key=False): return extra_specs +def add_volume_type_access(context, volume_type_id, project_id): + """Add access to volume type for project_id.""" + if volume_type_id is None: + msg = _("volume_type_id cannot be None") + raise exception.InvalidVolumeType(reason=msg) + return db.volume_type_access_add(context, volume_type_id, project_id) + + +def remove_volume_type_access(context, volume_type_id, project_id): + """Remove access to volume type for project_id.""" + if volume_type_id is None: + msg = _("volume_type_id cannot be None") + raise exception.InvalidVolumeType(reason=msg) + return db.volume_type_access_remove(context, volume_type_id, project_id) + + def is_encrypted(context, volume_type_id): if volume_type_id is None: return False diff --git a/etc/cinder/policy.json b/etc/cinder/policy.json index ab7fdda10ac..36816060f96 100644 --- a/etc/cinder/policy.json +++ b/etc/cinder/policy.json @@ -21,6 +21,9 @@ "volume_extension:types_manage": "rule:admin_api", "volume_extension:types_extra_specs": "rule:admin_api", + "volume_extension:volume_type_access": "", + "volume_extension:volume_type_access:addProjectAccess": "rule:admin_api", + "volume_extension:volume_type_access:removeProjectAccess": "rule:admin_api", "volume_extension:volume_type_encryption": "rule:admin_api", "volume_extension:volume_encryption_metadata": "rule:admin_or_owner", "volume_extension:extended_snapshot_attributes": "",