diff --git a/api-ref/source/v3/default-types.inc b/api-ref/source/v3/default-types.inc new file mode 100644 index 00000000000..a63265c9ec3 --- /dev/null +++ b/api-ref/source/v3/default-types.inc @@ -0,0 +1,167 @@ +.. -*- rst -*- + +Default Volume Types (default-types) +==================================== + +Manage a default volume type for individual projects. + +By default, a volume-create request that does not specify a volume-type +will assign the configured system default volume type to the volume. +You can override this behavior on a per-project basis by setting a different +default volume type for any project. + +Available in microversion 3.62 or higher. + +NOTE: The default policy for list API is system admin so you would require +a system scoped token to access it. +To get a system scoped token, you need to run the following command: + +openstack --os-system-scope all --os-project-name='' token issue + +Create or update a default volume type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: PUT /v3/default-types/{project-id} + +Create or update the default volume type for a project + +Response codes +-------------- + +.. rest_status_code:: success ../status.yaml + + - 200 + +.. rest_status_code:: error ../status.yaml + + - 400 + - 404 + +Request Parameters +------------------ + +.. rest_parameters:: parameters.yaml + + - project_id: project_id_path + - volume_type: volume_type_name_or_id + +Request Example +--------------- + +.. literalinclude:: ./samples/set-default-type-request.json + :language: javascript + + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - project_id: project_id + - volume_type_id: volume_type_id + +Response Example +---------------- + +.. literalinclude:: ./samples/set-default-type-response.json + :language: javascript + +Show a default volume type +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: GET /v3/default-types/{project-id} + +Show the default volume type for a project + +Response codes +-------------- + +.. rest_status_code:: success ../status.yaml + + - 200 + +.. rest_status_code:: error ../status.yaml + + - 404 + +Request Parameters +------------------ + +.. rest_parameters:: parameters.yaml + + - project_id: project_id_path + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - project_id: project_id + - volume_type_id: volume_type_id + +Response Example +---------------- + +.. literalinclude:: ./samples/get-default-type-response.json + :language: javascript + +List default volume types +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: GET /v3/default-types/ + +Get a list of all default volume types + +Response codes +-------------- + +.. rest_status_code:: success ../status.yaml + + - 200 + +.. rest_status_code:: error ../status.yaml + + - 404 + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - project_id: project_id + - volume_type_id: volume_type_id + +Response Example +---------------- + +.. literalinclude:: ./samples/get-default-types-response.json + :language: javascript + +Delete a default volume type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: DELETE /v3/default-types/{project-id} + +Unset the default volume type for a project. + +This operation does not do anything to the volume type itself. +It simply removes the volume type from being the default volume type for +the specified project. + +Response codes +-------------- + +.. rest_status_code:: success ../status.yaml + + - 204 + +.. rest_status_code:: error ../status.yaml + + - 404 + +Request Parameters +------------------ + +.. rest_parameters:: parameters.yaml + + - project_id: project_id_path \ No newline at end of file diff --git a/api-ref/source/v3/index.rst b/api-ref/source/v3/index.rst index 68437db9ce7..e3474c15af3 100644 --- a/api-ref/source/v3/index.rst +++ b/api-ref/source/v3/index.rst @@ -16,6 +16,7 @@ Block Storage API V3 (CURRENT) .. To create a volume, I might need a volume type, so list those next. .. include:: volumes-v3-types.inc .. include:: volume-type-access.inc +.. include:: default-types.inc .. Now my primary focus is on volumes and what I can do with them. .. include:: volumes-v3-volumes.inc diff --git a/api-ref/source/v3/parameters.yaml b/api-ref/source/v3/parameters.yaml index 528a3268fe8..a021105a26b 100644 --- a/api-ref/source/v3/parameters.yaml +++ b/api-ref/source/v3/parameters.yaml @@ -168,6 +168,12 @@ volume_type_id: in: path required: true type: string +volume_type_name_or_id: + description: | + The name or UUID for an existing volume type. + in: path + required: true + type: string # variables in query action: diff --git a/api-ref/source/v3/samples/get-default-type-response.json b/api-ref/source/v3/samples/get-default-type-response.json new file mode 100644 index 00000000000..af994a2f6bd --- /dev/null +++ b/api-ref/source/v3/samples/get-default-type-response.json @@ -0,0 +1,6 @@ +{ + "default_type": { + "project_id": "6685584b-1eac-4da6-b5c3-555430cf68ff", + "volume_type_id": "40ec6e5e-c9bd-4170-8740-c1cd42d7eabb" + } +} \ No newline at end of file diff --git a/api-ref/source/v3/samples/get-default-types-response.json b/api-ref/source/v3/samples/get-default-types-response.json new file mode 100644 index 00000000000..b03a476c081 --- /dev/null +++ b/api-ref/source/v3/samples/get-default-types-response.json @@ -0,0 +1,12 @@ +{ + "default_types": [ + { + "project_id": "6685584b-1eac-4da6-b5c3-555430cf68ff", + "volume_type_id": "40ec6e5e-c9bd-4170-8740-c1cd42d7eabb" + }, + { + "project_id": "dd46ea3e-6f3f-4e50-85fa-40c182e25d12", + "volume_type_id": "9fb51b63-3cd4-493f-9380-53d8f0a04bd4" + } + ] +} \ No newline at end of file diff --git a/api-ref/source/v3/samples/set-default-type-request.json b/api-ref/source/v3/samples/set-default-type-request.json new file mode 100644 index 00000000000..112b4873963 --- /dev/null +++ b/api-ref/source/v3/samples/set-default-type-request.json @@ -0,0 +1,5 @@ +{ + "default_type": { + "volume_type": "lvm_backend" + } +} \ No newline at end of file diff --git a/api-ref/source/v3/samples/set-default-type-response.json b/api-ref/source/v3/samples/set-default-type-response.json new file mode 100644 index 00000000000..af994a2f6bd --- /dev/null +++ b/api-ref/source/v3/samples/set-default-type-response.json @@ -0,0 +1,6 @@ +{ + "default_type": { + "project_id": "6685584b-1eac-4da6-b5c3-555430cf68ff", + "volume_type_id": "40ec6e5e-c9bd-4170-8740-c1cd42d7eabb" + } +} \ No newline at end of file diff --git a/api-ref/source/v3/samples/versions/version-show-response.json b/api-ref/source/v3/samples/versions/version-show-response.json index 5dfc2d47a9e..505fbe2d891 100644 --- a/api-ref/source/v3/samples/versions/version-show-response.json +++ b/api-ref/source/v3/samples/versions/version-show-response.json @@ -22,7 +22,7 @@ "min_version": "3.0", "status": "CURRENT", "updated": "2018-07-17T00:00:00Z", - "version": "3.61" + "version": "3.62" } ] } diff --git a/api-ref/source/v3/samples/versions/versions-response.json b/api-ref/source/v3/samples/versions/versions-response.json index ee73b288139..7718667ffdb 100644 --- a/api-ref/source/v3/samples/versions/versions-response.json +++ b/api-ref/source/v3/samples/versions/versions-response.json @@ -46,7 +46,7 @@ "min_version": "3.0", "status": "CURRENT", "updated": "2018-07-17T00:00:00Z", - "version": "3.61" + "version": "3.62" } ] } diff --git a/cinder/api/microversions.py b/cinder/api/microversions.py index 8134e0ae03d..f1b9bda6edd 100644 --- a/cinder/api/microversions.py +++ b/cinder/api/microversions.py @@ -163,6 +163,8 @@ VOLUME_TIME_COMPARISON_FILTER = '3.60' VOLUME_CLUSTER_NAME = '3.61' +DEFAULT_TYPE_OVERRIDES = '3.62' + def get_mv_header(version): """Gets a formatted HTTP microversion header. diff --git a/cinder/api/openstack/api_version_request.py b/cinder/api/openstack/api_version_request.py index c50affb4b96..966b5d22396 100644 --- a/cinder/api/openstack/api_version_request.py +++ b/cinder/api/openstack/api_version_request.py @@ -141,6 +141,7 @@ REST_API_VERSION_HISTORY = """ ("GET /v3/{project_id}/volumes/detail") requests. * 3.61 - Add ``cluster_name`` attribute to response body of volume details for admin. + * 3.62 - Default volume type overrides """ # The minimum and maximum versions of the API supported @@ -148,9 +149,9 @@ REST_API_VERSION_HISTORY = """ # minimum version of the API supported. # Explicitly using /v2 endpoints will still work _MIN_API_VERSION = "3.0" -_MAX_API_VERSION = "3.61" +_MAX_API_VERSION = "3.62" _LEGACY_API_VERSION2 = "2.0" -UPDATED = "2018-07-17T00:00:00Z" +UPDATED = "2020-10-14T00:00:00Z" # NOTE(cyeoh): min and max versions declared as functions so we can diff --git a/cinder/api/openstack/rest_api_version_history.rst b/cinder/api/openstack/rest_api_version_history.rst index 6db8e6fc9cd..d6ad94a2b70 100644 --- a/cinder/api/openstack/rest_api_version_history.rst +++ b/cinder/api/openstack/rest_api_version_history.rst @@ -473,3 +473,9 @@ Time must be expressed in ISO 8601 format. ---- Add ``cluster_name`` attribute to response body of volume details for admin in Active/Active HA mode. + +3.62 +---- +Add support for set, get, and unset a default volume type for a specific +project. Setting this default overrides the configured default_volume_type +value. diff --git a/cinder/api/schemas/default_types.py b/cinder/api/schemas/default_types.py new file mode 100644 index 00000000000..ce462e6fb6f --- /dev/null +++ b/cinder/api/schemas/default_types.py @@ -0,0 +1,34 @@ +# Copyright 2020 Red Hat, Inc. +# 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. +""" +Schema for V3 Default types API. + +""" +from cinder.api.validation import parameter_types + + +create_or_update = { + 'type': 'object', + 'properties': { + 'default_type': { + 'type': 'object', + 'properties': { + 'volume_type': parameter_types.name, + }, + 'required': ['volume_type'], + 'additionalProperties': False, + }, + } +} diff --git a/cinder/api/v2/types.py b/cinder/api/v2/types.py index a319c822ca1..ae19cbedf16 100644 --- a/cinder/api/v2/types.py +++ b/cinder/api/v2/types.py @@ -52,7 +52,7 @@ class VolumeTypesController(wsgi.Controller): # get default volume type if id is not None and id == 'default': - vol_type = volume_types.get_default_volume_type() + vol_type = volume_types.get_default_volume_type(context) if not vol_type: msg = _("Default volume type can not be found.") raise exception.VolumeTypeNotFound(message=msg) diff --git a/cinder/api/v3/default_types.py b/cinder/api/v3/default_types.py new file mode 100644 index 00000000000..faf4fd20142 --- /dev/null +++ b/cinder/api/v3/default_types.py @@ -0,0 +1,127 @@ +# Copyright 2020 Red Hat, Inc. +# 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. + +"""The resource filters api.""" + +from keystoneauth1 import exceptions as ks_exc +from six.moves import http_client +from webob import exc + +from cinder.api import microversions as mv +from cinder.api.openstack import wsgi +from cinder.api.schemas import default_types as default_types +from cinder.api.v3.views import default_types as default_types_view +from cinder.api import validation +from cinder import db +from cinder import exception +from cinder.i18n import _ +from cinder import objects +from cinder.policies import default_types as policy +from cinder import quota_utils + + +class DefaultTypesController(wsgi.Controller): + """The Default types API controller for the OpenStack API.""" + + _view_builder_class = default_types_view.ViewBuilder + + def _validate_project_and_authorize(self, context, project_id, + policy_check): + try: + target_project = quota_utils.get_project_hierarchy(context, + project_id) + target_project = {'project_id': target_project.id, + 'domain_id': target_project.domain_id} + context.authorize(policy_check, target=target_project) + except ks_exc.http.NotFound: + explanation = _("Project with id %s not found." % project_id) + raise exc.HTTPNotFound(explanation=explanation) + except exception.NotAuthorized: + explanation = _("You are not authorized to perform this " + "operation.") + raise exc.HTTPForbidden(explanation=explanation) + + @wsgi.response(http_client.OK) + @wsgi.Controller.api_version(mv.DEFAULT_TYPE_OVERRIDES) + @validation.schema(default_types.create_or_update) + def create_update(self, req, id, body): + """Set a default volume type for the specified project.""" + context = req.environ['cinder.context'] + + project_id = id + volume_type_id = body['default_type']['volume_type'] + + self._validate_project_and_authorize(context, project_id, + policy.CREATE_UPDATE_POLICY) + try: + volume_type_id = objects.VolumeType.get_by_name_or_id( + context, volume_type_id).id + + except exception.VolumeTypeNotFound as e: + raise exc.HTTPBadRequest(explanation=e.msg) + + default_type = db.project_default_volume_type_set( + context, volume_type_id, project_id) + + return self._view_builder.create(default_type) + + @wsgi.response(http_client.OK) + @wsgi.Controller.api_version(mv.DEFAULT_TYPE_OVERRIDES) + def detail(self, req, id): + """Return detail of a default type.""" + + context = req.environ['cinder.context'] + + project_id = id + self._validate_project_and_authorize(context, project_id, + policy.GET_POLICY) + default_type = db.project_default_volume_type_get(context, project_id) + if not default_type: + raise exception.VolumeTypeProjectDefaultNotFound( + project_id=project_id) + return self._view_builder.detail(default_type) + + @wsgi.response(http_client.OK) + @wsgi.Controller.api_version(mv.DEFAULT_TYPE_OVERRIDES) + def index(self, req): + """Return a list of default types.""" + + context = req.environ['cinder.context'] + try: + context.authorize(policy.GET_ALL_POLICY) + except exception.NotAuthorized: + explanation = _("You are not authorized to perform this " + "operation.") + raise exc.HTTPForbidden(explanation=explanation) + + default_types = db.project_default_volume_type_get(context) + return self._view_builder.index(default_types) + + @wsgi.response(http_client.NO_CONTENT) + @wsgi.Controller.api_version(mv.DEFAULT_TYPE_OVERRIDES) + def delete(self, req, id): + """Unset a default volume type for a project.""" + + context = req.environ['cinder.context'] + + project_id = id + self._validate_project_and_authorize(context, project_id, + policy.DELETE_POLICY) + db.project_default_volume_type_unset(context, id) + + +def create_resource(): + """Create the wsgi resource for this controller.""" + return wsgi.Resource(DefaultTypesController()) diff --git a/cinder/api/v3/router.py b/cinder/api/v3/router.py index 8bf3229055a..dad5585190d 100644 --- a/cinder/api/v3/router.py +++ b/cinder/api/v3/router.py @@ -27,6 +27,7 @@ from cinder.api.v3 import attachments from cinder.api.v3 import backups from cinder.api.v3 import clusters from cinder.api.v3 import consistencygroups +from cinder.api.v3 import default_types from cinder.api.v3 import group_snapshots from cinder.api.v3 import group_specs from cinder.api.v3 import group_types @@ -199,3 +200,24 @@ class APIRouter(cinder.api.openstack.APIRouter): controller=self.resources['volume_transfers'], collection={'detail': 'GET'}, member={'accept': 'POST'}) + + self.resources['default_types'] = default_types.create_resource() + mapper.connect("default-types", "/default-types/{id}", + controller=self.resources['default_types'], + action='create_update', + conditions={"method": ['PUT']}) + + mapper.connect("default-types", "/default-types", + controller=self.resources['default_types'], + action='index', + conditions={"method": ['GET']}) + + mapper.connect("default-types", "/default-types/{id}", + controller=self.resources['default_types'], + action='detail', + conditions={"method": ['GET']}) + + mapper.connect("default-types", "/default-types/{id}", + controller=self.resources['default_types'], + action='delete', + conditions={"method": ['DELETE']}) diff --git a/cinder/api/v3/views/default_types.py b/cinder/api/v3/views/default_types.py new file mode 100644 index 00000000000..924fe710417 --- /dev/null +++ b/cinder/api/v3/views/default_types.py @@ -0,0 +1,68 @@ +# Copyright 2020 Red Hat, Inc. +# 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. + + +class ViewBuilder(object): + """Model default type API response as a python dictionary.""" + + _collection_name = "default_types" + + def _convert_to_dict(self, default): + return {'project_id': default.project_id, + 'volume_type_id': default.volume_type_id} + + def create(self, default_type): + """Detailed view of a default type when set.""" + + return {'default_type': self._convert_to_dict(default_type)} + + def index(self, default_types): + """Build a view of a list of default types. + + .. code-block:: json + + {"default_types": + [ + { + "project_id": "248592b4-a6da-4c4c-abe0-9d8dbe0b74b4", + "volume_type_id": "7152eb1e-aef0-4bcd-a3ab-46b7ef17e2e6" + }, + { + "project_id": "1234567-4c4c-abcd-abe0-1a2b3c4d5e6ff", + "volume_type_id": "5e3b298a-f1fc-4d32-9828-0d720da81ddd" + } + ] + } + """ + + default_types_view = [] + for default_type in default_types: + default_types_view.append(self._convert_to_dict(default_type)) + + return {'default_types': default_types_view} + + def detail(self, default_type): + """Build a view of a default type. + + .. code-block:: json + + {"default_type": + { + "project_id": "248592b4-a6da-4c4c-abe0-9d8dbe0b74b4", + "volume_type_id": "6bd1de9a-b8b5-4c43-a597-00170ab06b50" + } + } + """ + return {'default_type': self._convert_to_dict(default_type)} diff --git a/cinder/db/api.py b/cinder/db/api.py index b53b5713403..d1071a2cdfd 100644 --- a/cinder/db/api.py +++ b/cinder/db/api.py @@ -711,6 +711,27 @@ def volume_type_access_remove(context, type_id, project_id): return IMPL.volume_type_access_remove(context, type_id, project_id) +def project_default_volume_type_set(context, volume_type_id, project_id): + """Set default volume type for a project""" + return IMPL.project_default_volume_type_set(context, volume_type_id, + project_id) + + +def project_default_volume_type_get(context, project_id=None): + """Get default volume type for a project""" + return IMPL.project_default_volume_type_get(context, project_id) + + +def project_default_volume_type_unset(context, project_id): + """Unset default volume type for a project (hard delete)""" + return IMPL.project_default_volume_type_unset(context, project_id) + + +def get_all_projects_with_default_type(context, volume_type_id): + """Get all the projects associated with a default type""" + return IMPL.get_all_projects_with_default_type(context, volume_type_id) + + #################### diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index e0d86096dba..fb853235bce 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -20,6 +20,7 @@ import collections from collections import abc +import contextlib import datetime as dt import functools import itertools @@ -4306,6 +4307,65 @@ def volume_type_access_remove(context, type_id, project_id): volume_type_id=type_id, project_id=project_id) +def project_default_volume_type_set(context, volume_type_id, project_id): + """Set default volume type for a project""" + + session = get_session() + with session.begin(): + update_default = project_default_volume_type_get(context, project_id, + session=session) + if update_default: + LOG.info("Updating default type for project %s", project_id) + update_default.volume_type_id = volume_type_id + return update_default + + access_ref = models.DefaultVolumeTypes(volume_type_id=volume_type_id, + project_id=project_id) + access_ref.save(session=session) + return access_ref + + +def project_default_volume_type_get(context, project_id=None, session=None): + """Get default volume type(s) for a project(s) + + If a project id is passed, it returns default type for that particular + project else returns default volume types for all projects + """ + if session: + # This is requested by set method. + # To avoid race condition, we use the same session here + session_ctxt = contextlib.suppress() + else: + session = get_session() + session_ctxt = session.begin() + with session_ctxt: + if project_id: + return model_query(context, models.DefaultVolumeTypes, + session=session).\ + filter_by(project_id=project_id).first() + return model_query(context, models.DefaultVolumeTypes, + session=session).all() + + +def get_all_projects_with_default_type(context, volume_type_id): + """Get all projects with volume_type_id as their default type""" + session = get_session() + with session.begin(): + return model_query(context, models.DefaultVolumeTypes, + session=session).\ + filter_by(volume_type_id=volume_type_id).all() + + +def project_default_volume_type_unset(context, project_id): + """Unset default volume type for a project (hard delete)""" + + session = get_session() + with session.begin(): + (model_query(context, models.DefaultVolumeTypes, + session=session). + filter_by(project_id=project_id).delete()) + + @require_admin_context def group_type_access_remove(context, type_id, project_id): """Remove given tenant from the group type access list.""" diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/140_create_project_default_volume_type.py b/cinder/db/sqlalchemy/migrate_repo/versions/140_create_project_default_volume_type.py new file mode 100644 index 00000000000..4720b9f48e8 --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/140_create_project_default_volume_type.py @@ -0,0 +1,45 @@ +# Copyright 2020 Red Hat, Inc. +# 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 sqlalchemy import Boolean, Column, DateTime +from sqlalchemy import MetaData, String, Table, ForeignKey + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + # This is required to establish foreign key dependency between + # volume_type_id and volume_types.id columns. See L#34-35 + Table('volume_types', meta, autoload=True) + + default_volume_types = Table( + 'default_volume_types', meta, + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('deleted_at', DateTime), + Column('volume_type_id', String(36), + ForeignKey('volume_types.id'), index=True), + Column('project_id', String(length=255), unique=True, + primary_key=True, nullable=False), + Column('deleted', Boolean(create_constraint=True, name=None)), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + try: + default_volume_types.create() + except Exception: + raise diff --git a/cinder/db/sqlalchemy/models.py b/cinder/db/sqlalchemy/models.py index 906efd500e7..4f2cfca7f31 100644 --- a/cinder/db/sqlalchemy/models.py +++ b/cinder/db/sqlalchemy/models.py @@ -513,6 +513,18 @@ class GroupTypeSpecs(BASE, CinderBase): ) +class DefaultVolumeTypes(BASE, CinderBase): + """Represent projects associated volume_types.""" + __tablename__ = "default_volume_types" + volume_type_id = Column(String, ForeignKey('volume_types.id'), + nullable=False, index=True) + project_id = Column(String(255), unique=True, primary_key=True) + volume_type = relationship( + VolumeType, + foreign_keys=volume_type_id, + primaryjoin='DefaultVolumeTypes.volume_type_id == VolumeType.id') + + class QualityOfServiceSpecs(BASE, CinderBase): """Represents QoS specs as key/value pairs. diff --git a/cinder/exception.py b/cinder/exception.py index 58684498b6a..b040166c9b3 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -392,7 +392,7 @@ class VolumeTypeDeletionError(Invalid): class VolumeTypeDefaultDeletionError(Invalid): - message = _("The volume type %(volume_type_id)s is the default volume " + message = _("The volume type %(volume_type_id)s is a default volume " "type and cannot be deleted.") @@ -401,6 +401,10 @@ class VolumeTypeDefaultMisconfiguredError(CinderException): "%(volume_type_name)s cannot be found.") +class VolumeTypeProjectDefaultNotFound(NotFound): + message = _("Default type for project %(project_id)s not found.") + + class GroupTypeNotFound(NotFound): message = _("Group type %(group_type_id)s could not be found.") diff --git a/cinder/policies/__init__.py b/cinder/policies/__init__.py index 9cfc4acbbd4..8e42933eec8 100644 --- a/cinder/policies/__init__.py +++ b/cinder/policies/__init__.py @@ -21,6 +21,7 @@ from cinder.policies import backups from cinder.policies import base from cinder.policies import capabilities from cinder.policies import clusters +from cinder.policies import default_types from cinder.policies import group_actions from cinder.policies import group_snapshot_actions from cinder.policies import group_snapshots @@ -83,4 +84,5 @@ def list_rules(): volume_metadata.list_rules(), type_extra_specs.list_rules(), volumes.list_rules(), + default_types.list_rules(), ) diff --git a/cinder/policies/base.py b/cinder/policies/base.py index c68af8a2d73..664aac3015f 100644 --- a/cinder/policies/base.py +++ b/cinder/policies/base.py @@ -18,6 +18,10 @@ from oslo_policy import policy RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner' RULE_ADMIN_API = 'rule:admin_api' +SYSTEM_ADMIN = 'role:admin and system_scope:all' + +SYSTEM_OR_DOMAIN_OR_PROJECT_ADMIN = 'rule:system_or_domain_or_project_admin' + rules = [ policy.RuleDefault('context_is_admin', 'role:admin', description="Decides what is required for the " @@ -30,6 +34,12 @@ rules = [ 'is_admin:True or (role:admin and ' 'is_admin_project:True)', description="Default rule for most Admin APIs."), + policy.RuleDefault('system_or_domain_or_project_admin', + '(role:admin and system_scope:all) or ' + '(role:admin and domain_id:%(domain_id)s) or ' + '(role:admin and project_id:%(project_id)s)', + description="Default rule for admins of cloud, domain " + "or a project."), ] diff --git a/cinder/policies/default_types.py b/cinder/policies/default_types.py new file mode 100644 index 00000000000..0305aaaaa9f --- /dev/null +++ b/cinder/policies/default_types.py @@ -0,0 +1,76 @@ +# Copyright 2020 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_policy import policy + +from cinder.policies import base + +CREATE_UPDATE_POLICY = "volume_extension:default_set_or_update" +GET_POLICY = "volume_extension:default_get" +GET_ALL_POLICY = "volume_extension:default_get_all" +DELETE_POLICY = "volume_extension:default_unset" + +default_type_policies = [ + policy.DocumentedRuleDefault( + name=CREATE_UPDATE_POLICY, + check_str=base.SYSTEM_OR_DOMAIN_OR_PROJECT_ADMIN, + scope_types=['system'], + description="Set or update default volume type.", + operations=[ + { + 'method': 'PUT', + 'path': '/default-types' + } + ]), + policy.DocumentedRuleDefault( + name=GET_POLICY, + check_str=base.SYSTEM_OR_DOMAIN_OR_PROJECT_ADMIN, + scope_types=['system'], + description="Get default types.", + operations=[ + { + 'method': 'GET', + 'path': '/default-types/{project-id}' + } + ]), + policy.DocumentedRuleDefault( + name=GET_ALL_POLICY, + check_str=base.SYSTEM_ADMIN, + scope_types=['system'], + description="Get all default types. " + "WARNING: Changing this might open up too much " + "information regarding cloud deployment.", + operations=[ + { + 'method': 'GET', + 'path': '/default-types/' + } + ]), + policy.DocumentedRuleDefault( + name=DELETE_POLICY, + check_str=base.SYSTEM_OR_DOMAIN_OR_PROJECT_ADMIN, + scope_types=['system'], + description="Unset default type.", + operations=[ + { + 'method': 'DELETE', + 'path': '/default-types/{project-id}' + } + ]), +] + + +def list_rules(): + return default_type_policies diff --git a/cinder/policy.py b/cinder/policy.py index 51142520612..fcf763d37cb 100644 --- a/cinder/policy.py +++ b/cinder/policy.py @@ -155,20 +155,20 @@ def authorize(context, action, target, do_raise=True, exc=None): do_raise is False. """ init() - credentials = context.to_policy_values() if not exc: exc = exception.PolicyNotAuthorized try: - result = _ENFORCER.authorize(action, target, credentials, + result = _ENFORCER.authorize(action, target, context, do_raise=do_raise, exc=exc, action=action) except policy.PolicyNotRegistered: with excutils.save_and_reraise_exception(): LOG.exception('Policy not registered') except Exception: with excutils.save_and_reraise_exception(): - LOG.error('Policy check for %(action)s failed with credentials ' + LOG.error('Policy check for %(action)s failed with context ' '%(credentials)s', - {'action': action, 'credentials': credentials}) + {'action': action, + 'credentials': context.to_policy_values()}) return result diff --git a/cinder/quota_utils.py b/cinder/quota_utils.py index f047306ac01..3617fe49ac0 100644 --- a/cinder/quota_utils.py +++ b/cinder/quota_utils.py @@ -36,8 +36,10 @@ class GenericProjectInfo(object): project_parent_id=None, project_subtree=None, project_parent_tree=None, - is_admin_project=False): + is_admin_project=False, + domain_id=None): self.id = project_id + self.domain_id = domain_id self.keystone_api_version = project_keystone_api_version self.parent_id = project_parent_id self.subtree = project_subtree @@ -106,6 +108,7 @@ def get_project_hierarchy(context, project_id, subtree_as_ids=False, parents_as_ids=parents_as_ids) generic_project.parent_id = None + generic_project.domain_id = project.domain_id if project.parent_id != project.domain_id: generic_project.parent_id = project.parent_id diff --git a/cinder/tests/functional/api/client.py b/cinder/tests/functional/api/client.py index bedc30bed92..f65491b9a71 100644 --- a/cinder/tests/functional/api/client.py +++ b/cinder/tests/functional/api/client.py @@ -56,6 +56,10 @@ class OpenStackApiException400(OpenStackApiException): message = _("400 Bad Request") +class OpenStackApiException403(OpenStackApiException): + message = _("403 Forbidden") + + class OpenStackApiException500(OpenStackApiException): message = _("500 Internal Server Error") @@ -129,11 +133,15 @@ class TestOpenStackClient(object): self._authenticate(True) def api_request(self, relative_uri, check_response_status=None, - strip_version=False, **kwargs): + strip_version=False, base_url=True, **kwargs): auth_result = self._authenticate() - # NOTE(justinsb): httplib 'helpfully' converts headers to lower case - base_uri = auth_result['x-server-management-url'] + if base_url: + # NOTE(justinsb): httplib 'helpfully' converts headers to lower + # case + base_uri = auth_result['x-server-management-url'] + else: + base_uri = self.auth_uri if strip_version: # cut out version number and tenant_id @@ -169,12 +177,12 @@ class TestOpenStackClient(object): else: return "" - def api_get(self, relative_uri, **kwargs): + def api_get(self, relative_uri, base_url=True, **kwargs): kwargs.setdefault('check_response_status', [http_client.OK]) - response = self.api_request(relative_uri, **kwargs) + response = self.api_request(relative_uri, base_url=base_url, **kwargs) return self._decode_json(response) - def api_post(self, relative_uri, body, **kwargs): + def api_post(self, relative_uri, body, base_url=True, **kwargs): kwargs['method'] = 'POST' if body: headers = kwargs.setdefault('headers', {}) @@ -183,10 +191,10 @@ class TestOpenStackClient(object): kwargs.setdefault('check_response_status', [http_client.OK, http_client.ACCEPTED]) - response = self.api_request(relative_uri, **kwargs) + response = self.api_request(relative_uri, base_url=base_url, **kwargs) return self._decode_json(response) - def api_put(self, relative_uri, body, **kwargs): + def api_put(self, relative_uri, body, base_url=True, **kwargs): kwargs['method'] = 'PUT' if body: headers = kwargs.setdefault('headers', {}) @@ -196,15 +204,15 @@ class TestOpenStackClient(object): kwargs.setdefault('check_response_status', [http_client.OK, http_client.ACCEPTED, http_client.NO_CONTENT]) - response = self.api_request(relative_uri, **kwargs) + response = self.api_request(relative_uri, base_url=base_url, **kwargs) return self._decode_json(response) - def api_delete(self, relative_uri, **kwargs): + def api_delete(self, relative_uri, base_url=True, **kwargs): kwargs['method'] = 'DELETE' kwargs.setdefault('check_response_status', [http_client.OK, http_client.ACCEPTED, http_client.NO_CONTENT]) - return self.api_request(relative_uri, **kwargs) + return self.api_request(relative_uri, base_url=base_url, **kwargs) def get_volume(self, volume_id): return self.api_get('/volumes/%s' % volume_id)['volume'] @@ -329,3 +337,19 @@ class TestOpenStackClient(object): def list_group_replication_targets(self, group_id, params): return self.api_post('/groups/%s/action' % group_id, params) + + def set_default_type(self, project_id, params): + body = {"default_type": params} + return self.api_put('default-types/%s' % project_id, body, + base_url=False)['default_type'] + + def get_default_type(self, project_id=None): + if project_id: + return self.api_get('default-types/%s' % project_id, + base_url=False)['default_type'] + return self.api_get('default-types', + base_url=False)['default_types'] + + def unset_default_type(self, project_id): + self.api_delete('default-types/%s' % project_id, + base_url=False) diff --git a/cinder/tests/functional/test_default_types.py b/cinder/tests/functional/test_default_types.py new file mode 100644 index 00000000000..1277f95752c --- /dev/null +++ b/cinder/tests/functional/test_default_types.py @@ -0,0 +1,124 @@ +# Copyright 2020 Red Hat, Inc. +# 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 unittest import mock +import uuid + +from cinder import context +from cinder.tests.functional.api import client +from cinder.tests.functional import functional_helpers + + +class DefaultVolumeTypesTest(functional_helpers._FunctionalTestBase): + _vol_type_name = 'functional_test_type' + osapi_version_minor = '62' + + def setUp(self): + super(DefaultVolumeTypesTest, self).setUp() + self.volume_type = self.api.create_type(self._vol_type_name) + self.project = self.FakeProject() + # Need to mock out Keystone so the functional tests don't require other + # services + _keystone_client = mock.MagicMock() + _keystone_client.version = 'v3' + _keystone_client.projects.get.side_effect = self._get_project + _keystone_client_get = mock.patch( + 'cinder.quota_utils._keystone_client', + lambda *args, **kwargs: _keystone_client) + _keystone_client_get.start() + self.addCleanup(_keystone_client_get.stop) + + def _get_project(self, project_id, *args, **kwargs): + return self.project + + class FakeProject(object): + _dom_id = uuid.uuid4().hex + + def __init__(self, parent_id=None): + self.id = uuid.uuid4().hex + self.parent_id = parent_id + self.domain_id = self._dom_id + self.subtree = None + self.parents = None + + @mock.patch.object(context.RequestContext, 'authorize') + def test_default_type_set(self, mock_authorize): + default_type = self.api.set_default_type( + self.project.id, {'volume_type': self._vol_type_name}) + self.assertEqual(self.project.id, default_type['project_id']) + self.assertEqual(self.volume_type['id'], + default_type['volume_type_id']) + + @mock.patch.object(context.RequestContext, 'authorize') + def test_default_type_get(self, mock_authorize): + self.api.set_default_type(self.project.id, + {'volume_type': self._vol_type_name}) + default_type = self.api.get_default_type(project_id=self.project.id) + + self.assertEqual(self.project.id, default_type['project_id']) + self.assertEqual(self.volume_type['id'], + default_type['volume_type_id']) + + @mock.patch.object(context.RequestContext, 'authorize') + def test_default_type_get_all(self, mock_authorize): + self.api.set_default_type(self.project.id, + {'volume_type': self._vol_type_name}) + default_types = self.api.get_default_type() + + self.assertEqual(1, len(default_types)) + self.assertEqual(self.project.id, default_types[0]['project_id']) + self.assertEqual(self.volume_type['id'], + default_types[0]['volume_type_id']) + + @mock.patch.object(context.RequestContext, 'authorize') + def test_default_type_unset(self, mock_authorize): + self.api.set_default_type(self.project.id, + {'volume_type': self._vol_type_name}) + + default_types = self.api.get_default_type() + self.assertEqual(1, len(default_types)) + self.api.unset_default_type(self.project.id) + default_types = self.api.get_default_type() + self.assertEqual(0, len(default_types)) + + def test_default_type_set_not_authorized(self): + self.assertRaises(client.OpenStackApiException403, + self.api.set_default_type, + self.project.id, + {'volume_type': self._vol_type_name}) + + @mock.patch.object(context.RequestContext, 'authorize') + def test_default_type_set_volume_type_not_found(self, mock_authorize): + self.assertRaises(client.OpenStackApiException400, + self.api.set_default_type, + self.project.id, + {'volume_type': 'fake_type'}) + + def test_default_type_get_not_authorized(self): + self.assertRaises(client.OpenStackApiException403, + self.api.get_default_type) + + def test_default_type_unset_not_authorized(self): + self.assertRaises(client.OpenStackApiException403, + self.api.unset_default_type, + self.project.id) + + @mock.patch.object(context.RequestContext, 'authorize') + def test_cannot_delete_project_default_type(self, mock_authorize): + default_type = self.api.set_default_type( + self.project.id, {'volume_type': self._vol_type_name}) + self.assertRaises(client.OpenStackApiException400, + self.api.delete_type, + default_type['volume_type_id']) diff --git a/cinder/tests/unit/api/fakes.py b/cinder/tests/unit/api/fakes.py index d30ed3d8b1a..0e4962e6d87 100644 --- a/cinder/tests/unit/api/fakes.py +++ b/cinder/tests/unit/api/fakes.py @@ -129,11 +129,13 @@ class HTTPRequest(webob.Request): kwargs['base_url'] = 'http://localhost/v3' use_admin_context = kwargs.pop('use_admin_context', False) version = kwargs.pop('version', api_version._MIN_API_VERSION) + system_scope = kwargs.pop('system_scope', None) out = os_wsgi.Request.blank(*args, **kwargs) out.environ['cinder.context'] = FakeRequestContext( fake.USER_ID, fake.PROJECT_ID, - is_admin=use_admin_context) + is_admin=use_admin_context, + system_scope=system_scope) out.api_version_request = api_version.APIVersionRequest(version) return out diff --git a/cinder/tests/unit/api/v2/test_types.py b/cinder/tests/unit/api/v2/test_types.py index 0cd89c392a7..60492350814 100644 --- a/cinder/tests/unit/api/v2/test_types.py +++ b/cinder/tests/unit/api/v2/test_types.py @@ -74,7 +74,7 @@ def return_volume_types_get_volume_type(context, id): return fake_volume_type(id) -def return_volume_types_get_default(): +def return_volume_types_get_default(context): return fake_volume_type(1) diff --git a/cinder/tests/unit/api/v3/test_default_types.py b/cinder/tests/unit/api/v3/test_default_types.py new file mode 100644 index 00000000000..4a287055fbf --- /dev/null +++ b/cinder/tests/unit/api/v3/test_default_types.py @@ -0,0 +1,227 @@ +# Copyright 2020 Red Hat, Inc. +# 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 unittest import mock + +import webob + +from cinder.api import microversions as mv +from cinder.api.v3 import default_types +from cinder import context +from cinder import exception +from cinder import objects +from cinder.tests.unit.api import fakes +from cinder.tests.unit import fake_constants as fake +from cinder.tests.unit import test + + +class DefaultVolumeTypesApiTest(test.TestCase): + + def _create_volume_type(self, ctxt, volume_type_name, extra_specs=None, + is_public=True, projects=None): + vol_type = objects.VolumeType(ctxt, + name=volume_type_name, + is_public=is_public, + description='', + extra_specs=extra_specs, + projects=projects) + vol_type.create() + return vol_type + + def _set_default_type_system_scope(self, project_id=fake.PROJECT_ID, + volume_type='volume_type1'): + body = { + 'default_type': + {'volume_type': volume_type} + } + req = fakes.HTTPRequest.blank('/v3/default-types/%s' % project_id, + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES, + system_scope='all') + res_dict = self.controller.create_update(req, id=project_id, + body=body) + return res_dict + + def _set_default_type_project_scope(self, project_id=fake.PROJECT_ID, + volume_type='volume_type1'): + body = { + 'default_type': + {'volume_type': volume_type} + } + req = fakes.HTTPRequest.blank('/v3/default-types/%s' % project_id, + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES) + res_dict = self.controller.create_update(req, id=project_id, + body=body) + return res_dict + + def setUp(self): + super(DefaultVolumeTypesApiTest, self).setUp() + self.controller = default_types.DefaultTypesController() + self.ctxt = context.RequestContext(user_id=fake.USER_ID, + project_id=fake.PROJECT_ID, + is_admin=True, + system_scope='all') + self.type1 = self._create_volume_type( + self.ctxt, 'volume_type1') + self.type2 = self._create_volume_type( + self.ctxt, 'volume_type2') + + get_patcher = mock.patch('cinder.quota_utils.get_project_hierarchy', + self._get_project) + get_patcher.start() + self.addCleanup(get_patcher.stop) + + class FakeProject(object): + + def __init__(self, id=fake.PROJECT_ID, domain_id=fake.DOMAIN_ID, + parent_id=None, is_admin_project=False): + self.id = id + self.domain_id = domain_id + + def _get_project(self, context, id, subtree_as_ids=False, + parents_as_ids=False, is_admin_project=False): + return self.FakeProject(id) + + def test_default_volume_types_create_update_system_admin(self): + res_dict = self._set_default_type_system_scope() + self.assertEqual(fake.PROJECT_ID, + res_dict['default_type']['project_id']) + self.assertEqual(self.type1.id, + res_dict['default_type']['volume_type_id']) + + def test_default_volume_types_create_update_project_admin(self): + res_dict = self._set_default_type_project_scope() + self.assertEqual(fake.PROJECT_ID, + res_dict['default_type']['project_id']) + self.assertEqual(self.type1.id, + res_dict['default_type']['volume_type_id']) + + def test_default_volume_types_detail_system_admin(self): + self._set_default_type_system_scope() + req = fakes.HTTPRequest.blank('/v3/default-types/%s' % fake.PROJECT_ID, + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES, + system_scope='all') + res_dict = self.controller.detail(req, fake.PROJECT_ID) + + self.assertEqual(fake.PROJECT_ID, + res_dict['default_type']['project_id']) + self.assertEqual(self.type1.id, + res_dict['default_type']['volume_type_id']) + + def test_default_volume_types_detail_project_admin(self): + self._set_default_type_project_scope() + req = fakes.HTTPRequest.blank('/v3/default-types/%s' % fake.PROJECT_ID, + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES) + res_dict = self.controller.detail(req, fake.PROJECT_ID) + + self.assertEqual(fake.PROJECT_ID, + res_dict['default_type']['project_id']) + self.assertEqual(self.type1.id, + res_dict['default_type']['volume_type_id']) + + def test_default_volume_types_detail_no_default_found(self): + req = fakes.HTTPRequest.blank('/v3/default-types/%s' % fake.PROJECT_ID, + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES, + system_scope='all') + self.assertRaises(exception.VolumeTypeProjectDefaultNotFound, + self.controller.detail, req, fake.PROJECT_ID) + + def test_default_volume_types_list(self): + req = fakes.HTTPRequest.blank('/v3/default-types/', + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES, + system_scope='all') + # Confirm this returns an empty list when no default types are set + res_dict = self.controller.index(req) + self.assertEqual(0, len(res_dict['default_types'])) + + self._set_default_type_system_scope() + self._set_default_type_system_scope(project_id=fake.PROJECT2_ID, + volume_type='volume_type2') + res_dict = self.controller.index(req) + + self.assertEqual(2, len(res_dict['default_types'])) + self.assertEqual(fake.PROJECT_ID, + res_dict['default_types'][0]['project_id']) + self.assertEqual(fake.PROJECT2_ID, + res_dict['default_types'][1]['project_id']) + + def test_default_volume_types_delete_system_admin(self): + self._set_default_type_system_scope() + req = fakes.HTTPRequest.blank('/v3/default-types/', + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES, + system_scope='all') + res_dict = self.controller.index(req) + self.assertEqual(1, len(res_dict['default_types'])) + + self.controller.delete(req, fake.PROJECT_ID) + res_dict_new = self.controller.index(req) + self.assertEqual(0, len(res_dict_new['default_types'])) + + def test_default_volume_types_delete_project_admin(self): + self._set_default_type_project_scope() + req = fakes.HTTPRequest.blank('/v3/default-types/', + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES) + req_admin = fakes.HTTPRequest.blank('/v3/default-types/', + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES, + system_scope='all') + res_dict = self.controller.index(req_admin) + self.assertEqual(1, len(res_dict['default_types'])) + + self.controller.delete(req, fake.PROJECT_ID) + res_dict_new = self.controller.index(req_admin) + self.assertEqual(0, len(res_dict_new['default_types'])) + + def test_default_volume_types_create_update_other_project(self): + body = { + 'default_type': + {'volume_type': 'volume_type1'} + } + req = fakes.HTTPRequest.blank('/v3/default-types/%s' % + fake.PROJECT_ID, + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES) + self.assertRaises(webob.exc.HTTPForbidden, + self.controller.create_update, req, + id=fake.PROJECT2_ID, body=body) + + def test_default_volume_types_detail_other_project(self): + req = fakes.HTTPRequest.blank('/v3/default-types/%s' % fake.PROJECT_ID, + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.detail, req, + fake.PROJECT2_ID) + + def test_default_volume_types_index_no_system_scope(self): + self._set_default_type_system_scope(project_id=fake.PROJECT2_ID, + volume_type='volume_type2') + req = fakes.HTTPRequest.blank('/v3/default-types/', + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.index, req) + + def test_default_volume_types_delete_other_project(self): + req = fakes.HTTPRequest.blank('/v3/default-types/', + use_admin_context=True, + version=mv.DEFAULT_TYPE_OVERRIDES) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.delete, req, + fake.PROJECT2_ID) diff --git a/cinder/tests/unit/api/v3/test_types.py b/cinder/tests/unit/api/v3/test_types.py index 9716e68376c..7b654ff00b6 100644 --- a/cinder/tests/unit/api/v3/test_types.py +++ b/cinder/tests/unit/api/v3/test_types.py @@ -13,10 +13,13 @@ from cinder.api import microversions as mv from cinder.api.v2 import types from cinder import context +from cinder import db +from cinder import exception from cinder import objects from cinder.tests.unit.api import fakes from cinder.tests.unit import fake_constants as fake from cinder.tests.unit import test +from cinder.volume import volume_types class VolumeTypesApiTest(test.TestCase): @@ -89,3 +92,19 @@ class VolumeTypesApiTest(test.TestCase): self.assertEqual( ['volume_type1', 'volume_type2'], sorted([az['name'] for az in res_dict['volume_types']])) + + def test_delete_non_project_default_type(self): + type = self._create_volume_type(self.ctxt, 'type1') + db.project_default_volume_type_set( + self.ctxt, fake.VOLUME_TYPE_ID, fake.PROJECT_ID) + volume_types.destroy(self.ctxt, type.id) + self.assertRaises(exception.VolumeTypeNotFound, + volume_types.get_by_name_or_id, + self.ctxt, type.id) + + def test_cannot_delete_project_default_type(self): + default_type = db.project_default_volume_type_set( + self.ctxt, fake.VOLUME_TYPE_ID, fake.PROJECT_ID) + self.assertRaises(exception.VolumeTypeDefaultDeletionError, + volume_types.destroy, + self.ctxt, default_type['volume_type_id']) diff --git a/cinder/tests/unit/db/test_default_types.py b/cinder/tests/unit/db/test_default_types.py new file mode 100644 index 00000000000..0cf8ef943d3 --- /dev/null +++ b/cinder/tests/unit/db/test_default_types.py @@ -0,0 +1,112 @@ +# Copyright 2020 Red Hat, Inc. +# 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. + +"""Tests for default volume types.""" + +from cinder import context +from cinder import db +from cinder.tests.unit import fake_constants as fake +from cinder.tests.unit import test + + +class DefaultVolumeTypesTestCase(test.TestCase): + """DB tests for default volume types.""" + + def setUp(self): + super(DefaultVolumeTypesTestCase, self).setUp() + self.ctxt = context.RequestContext(user_id=fake.USER_ID, + project_id=fake.PROJECT_ID, + is_admin=True) + + def test_default_type_set(self): + default_type = db.project_default_volume_type_set( + self.ctxt, fake.VOLUME_TYPE_ID, fake.PROJECT_ID) + self.assertEqual(fake.PROJECT_ID, default_type.project_id) + self.assertEqual(fake.VOLUME_TYPE_ID, default_type.volume_type_id) + db.project_default_volume_type_unset(self.ctxt, + default_type.project_id) + + def test_default_type_get(self): + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE_ID, + fake.PROJECT_ID) + default_type = db.project_default_volume_type_get( + self.ctxt, project_id=fake.PROJECT_ID) + self.assertEqual(fake.PROJECT_ID, default_type.project_id) + self.assertEqual(fake.VOLUME_TYPE_ID, default_type.volume_type_id) + db.project_default_volume_type_unset(self.ctxt, + default_type.project_id) + + def test_get_all_projects_by_default_type(self): + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE_ID, + fake.PROJECT_ID) + default_type = db.get_all_projects_with_default_type( + self.ctxt, volume_type_id=fake.VOLUME_TYPE_ID) + self.assertEqual(1, len(default_type)) + self.assertEqual(fake.PROJECT_ID, default_type[0].project_id) + + def test_default_type_get_all(self): + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE_ID, + fake.PROJECT_ID) + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE2_ID, + fake.PROJECT2_ID) + default_types = db.project_default_volume_type_get(self.ctxt) + self.assertEqual(2, len(default_types)) + db.project_default_volume_type_unset(self.ctxt, + default_types[0].project_id) + db.project_default_volume_type_unset(self.ctxt, + default_types[1].project_id) + + def test_default_type_delete(self): + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE_ID, + fake.PROJECT_ID) + default_types = db.project_default_volume_type_get(self.ctxt) + self.assertEqual(1, len(default_types)) + db.project_default_volume_type_unset(self.ctxt, + default_types[0].project_id) + default_types = db.project_default_volume_type_get(self.ctxt) + self.assertEqual(0, len(default_types)) + + def test_default_type_update(self): + default_type = db.project_default_volume_type_set( + self.ctxt, fake.VOLUME_TYPE_ID, fake.PROJECT_ID) + self.assertEqual(fake.PROJECT_ID, default_type.project_id) + self.assertEqual(fake.VOLUME_TYPE_ID, default_type.volume_type_id) + + # update to type 2 + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE2_ID, + fake.PROJECT_ID) + default_type = db.project_default_volume_type_get( + self.ctxt, project_id=fake.PROJECT_ID) + self.assertEqual(fake.PROJECT_ID, default_type.project_id) + self.assertEqual(fake.VOLUME_TYPE2_ID, default_type.volume_type_id) + + # update to type 3 + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE3_ID, + fake.PROJECT_ID) + default_type = db.project_default_volume_type_get( + self.ctxt, project_id=fake.PROJECT_ID) + self.assertEqual(fake.PROJECT_ID, default_type.project_id) + self.assertEqual(fake.VOLUME_TYPE3_ID, default_type.volume_type_id) + + # back to original + db.project_default_volume_type_set(self.ctxt, fake.VOLUME_TYPE_ID, + fake.PROJECT_ID) + default_type = db.project_default_volume_type_get( + self.ctxt, project_id=fake.PROJECT_ID) + self.assertEqual(fake.PROJECT_ID, default_type.project_id) + self.assertEqual(fake.VOLUME_TYPE_ID, default_type.volume_type_id) + + db.project_default_volume_type_unset(self.ctxt, + default_type.project_id) diff --git a/cinder/tests/unit/fake_constants.py b/cinder/tests/unit/fake_constants.py index 14c116fb7b2..ebdf7b903fa 100644 --- a/cinder/tests/unit/fake_constants.py +++ b/cinder/tests/unit/fake_constants.py @@ -42,6 +42,7 @@ OBJECT3_ID = '7bf5ffa9-18a2-4b64-aab4-0798b53ee4e7' PROJECT_ID = '89afd400-b646-4bbc-b12b-c0a4d63e5bd3' PROJECT2_ID = '452ebfbc-55d9-402a-87af-65061916c24b' PROJECT3_ID = 'f6c912d7-bf30-4b12-af81-a9e0b2f85f85' +DOMAIN_ID = 'e747b880-4565-4d18-b8e2-310bdec83759' PROVIDER_ID = '60087173-e899-470a-9e3a-ba4cffa3e3e3' PROVIDER2_ID = '1060eccd-64bb-4ed2-86ce-aeaf135a97b8' PROVIDER3_ID = '63736819-1c95-440e-a873-b9d685afede5' diff --git a/cinder/tests/unit/policies/test_base.py b/cinder/tests/unit/policies/test_base.py index 972eea5d699..c9db2364fe7 100644 --- a/cinder/tests/unit/policies/test_base.py +++ b/cinder/tests/unit/policies/test_base.py @@ -34,6 +34,10 @@ class CinderPolicyTests(test.TestCase): user_id=fake_constants.USER_ID, project_id=self.project_id, roles=['admin'] ) + self.other_admin_context = cinder_context.RequestContext( + user_id=fake_constants.USER_ID, project_id=self.other_project_id, + roles=['admin'] + ) self.user_context = cinder_context.RequestContext( user_id=fake_constants.USER2_ID, project_id=self.project_id, roles=['non-admin'] @@ -42,6 +46,9 @@ class CinderPolicyTests(test.TestCase): user_id=fake_constants.USER3_ID, project_id=self.other_project_id, roles=['non-admin'] ) + self.system_admin_context = cinder_context.RequestContext( + user_id=fake_constants.USER_ID, project_id=self.project_id, + roles=['admin'], system_scope='all') fake_image.mock_image_service(self) def _get_request_response(self, context, path, method, body=None, diff --git a/cinder/tests/unit/policies/test_default_volume_types.py b/cinder/tests/unit/policies/test_default_volume_types.py new file mode 100644 index 00000000000..d19aba5939f --- /dev/null +++ b/cinder/tests/unit/policies/test_default_volume_types.py @@ -0,0 +1,203 @@ +# Copyright 2020 Red Hat, Inc. +# 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 unittest import mock + +from six.moves import http_client + +from cinder.api import microversions as mv +from cinder import db +from cinder.tests.unit import fake_constants +from cinder.tests.unit.policies import test_base + + +class DefaultVolumeTypesPolicyTests(test_base.CinderPolicyTests): + + class FakeDefaultType: + project_id = fake_constants.PROJECT_ID + volume_type_id = fake_constants.VOLUME_TYPE_ID + + def setUp(self): + super(DefaultVolumeTypesPolicyTests, self).setUp() + self.volume_type = self._create_fake_type(self.admin_context) + self.project = self.FakeProject() + # Need to mock out Keystone so the functional tests don't require other + # services + _keystone_client = mock.MagicMock() + _keystone_client.version = 'v3' + _keystone_client.projects.get.side_effect = self._get_project + _keystone_client_get = mock.patch( + 'cinder.quota_utils._keystone_client', + lambda *args, **kwargs: _keystone_client) + _keystone_client_get.start() + self.addCleanup(_keystone_client_get.stop) + + def _get_project(self, project_id, *args, **kwargs): + return self.project + + class FakeProject(object): + _dom_id = fake_constants.DOMAIN_ID + + def __init__(self, parent_id=None): + self.id = fake_constants.PROJECT_ID + self.parent_id = parent_id + self.domain_id = self._dom_id + self.subtree = None + self.parents = None + + def test_system_admin_can_set_default(self): + system_admin_context = self.system_admin_context + + path = '/v3/default-types/%s' % system_admin_context.project_id + body = { + 'default_type': + {"volume_type": self.volume_type.id} + } + response = self._get_request_response(system_admin_context, + path, 'PUT', body=body, + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.OK, response.status_int) + + def test_project_admin_can_set_default(self): + admin_context = self.admin_context + + path = '/v3/default-types/%s' % admin_context.project_id + body = { + 'default_type': + {"volume_type": self.volume_type.id} + } + response = self._get_request_response(admin_context, + path, 'PUT', body=body, + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.OK, response.status_int) + + def test_project_admin_cannot_set_default_for_other_project(self): + admin_context = self.admin_context + + path = '/v3/default-types/%s' % admin_context.project_id + body = { + 'default_type': + {"volume_type": self.volume_type.id} + } + response = self._get_request_response(self.other_admin_context, + path, 'PUT', body=body, + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.FORBIDDEN, response.status_int) + + @mock.patch.object(db, 'project_default_volume_type_get', + return_value=FakeDefaultType()) + def test_system_admin_can_get_default(self, mock_default_get): + system_admin_context = self.system_admin_context + + path = '/v3/default-types/%s' % system_admin_context.project_id + response = self._get_request_response(system_admin_context, + path, 'GET', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.OK, response.status_int) + + def test_project_admin_can_get_default(self): + admin_context = self.admin_context + + path = '/v3/default-types/%s' % admin_context.project_id + body = { + 'default_type': + {"volume_type": self.volume_type.id} + } + self._get_request_response(admin_context, + path, 'PUT', body=body, + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + path = '/v3/default-types/%s' % admin_context.project_id + response = self._get_request_response(admin_context, + path, 'GET', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.OK, response.status_int) + + def test_project_admin_cannot_get_default_for_other_project(self): + admin_context = self.admin_context + + path = '/v3/default-types/%s' % admin_context.project_id + response = self._get_request_response(self.other_admin_context, + path, 'GET', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.FORBIDDEN, response.status_int) + + def test_system_admin_can_get_all_default(self): + system_admin_context = self.system_admin_context + + path = '/v3/default-types' + response = self._get_request_response(system_admin_context, + path, 'GET', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.OK, response.status_int) + + def test_project_admin_cannot_get_all_default(self): + admin_context = self.admin_context + + path = '/v3/default-types' + response = self._get_request_response(admin_context, + path, 'GET', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.FORBIDDEN, response.status_int) + + def test_system_admin_can_unset_default(self): + system_admin_context = self.system_admin_context + + path = '/v3/default-types/%s' % system_admin_context.project_id + response = self._get_request_response(system_admin_context, + path, 'DELETE', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.NO_CONTENT, response.status_int) + + def test_project_admin_can_unset_default(self): + admin_context = self.admin_context + + path = '/v3/default-types/%s' % admin_context.project_id + response = self._get_request_response(admin_context, + path, 'DELETE', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.NO_CONTENT, response.status_int) + + def test_project_admin_cannot_unset_default_for_other_project(self): + admin_context = self.admin_context + + path = '/v3/default-types/%s' % admin_context.project_id + response = self._get_request_response(self.other_admin_context, + path, 'DELETE', + microversion= + mv.DEFAULT_TYPE_OVERRIDES) + + self.assertEqual(http_client.FORBIDDEN, response.status_int) diff --git a/cinder/tests/unit/test_quota_utils.py b/cinder/tests/unit/test_quota_utils.py index 92fa61c7450..977f45f6543 100644 --- a/cinder/tests/unit/test_quota_utils.py +++ b/cinder/tests/unit/test_quota_utils.py @@ -72,7 +72,7 @@ class QuotaUtilsTest(test.TestCase): del returned_project.subtree keystoneclient.projects.get.return_value = returned_project expected_project = quota_utils.GenericProjectInfo( - self.context.project_id, 'v3', 'bar') + self.context.project_id, 'v3', 'bar', domain_id='default') project = quota_utils.get_project_hierarchy( self.context, self.context.project_id) self.assertEqual(expected_project.__dict__, project.__dict__) @@ -86,7 +86,8 @@ class QuotaUtilsTest(test.TestCase): returned_project.subtree = subtree_dict keystoneclient.projects.get.return_value = returned_project expected_project = quota_utils.GenericProjectInfo( - self.context.project_id, 'v3', 'bar', subtree_dict) + self.context.project_id, 'v3', 'bar', subtree_dict, + domain_id='default') project = quota_utils.get_project_hierarchy( self.context, self.context.project_id, subtree_as_ids=True) keystoneclient.projects.get.assert_called_once_with( diff --git a/cinder/volume/flows/api/create_volume.py b/cinder/volume/flows/api/create_volume.py index 54c69f7cc88..7fdc0efcef8 100644 --- a/cinder/volume/flows/api/create_volume.py +++ b/cinder/volume/flows/api/create_volume.py @@ -379,7 +379,7 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): raise # otherwise, use the default volume type - return volume_types.get_default_volume_type() + return volume_types.get_default_volume_type(context) def execute(self, context, size, snapshot, image_id, source_volume, availability_zone, volume_type, metadata, key_manager, diff --git a/cinder/volume/volume_types.py b/cinder/volume/volume_types.py index 43309c6b798..14c028cfb0b 100644 --- a/cinder/volume/volume_types.py +++ b/cinder/volume/volume_types.py @@ -116,17 +116,28 @@ def destroy(context, id): if id is None: msg = _("id cannot be None") raise exception.InvalidVolumeType(reason=msg) - elevated = context if context.is_admin else context.elevated() + + projects_with_default_type = db.get_all_projects_with_default_type( + context.elevated(), id) + if len(projects_with_default_type) > 0: + # don't allow delete if the type requested is a project default + project_list = [p.project_id for p in projects_with_default_type] + LOG.exception('Default type with %(volume_type_id)s is associated ' + 'with projects %(projects)s', + {'volume_type_id': id, + 'projects': project_list}) + raise exception.VolumeTypeDefaultDeletionError(volume_type_id=id) # Default type *must* be set in order to delete any volume type. # If the default isn't set, the following call will raise # VolumeTypeDefaultMisconfiguredError exception which will error out the # delete operation. default_type = get_default_volume_type() - # don't allow delete if the type requested is the default type + # don't allow delete if the type requested is the conf default type if id == default_type.get('id'): raise exception.VolumeTypeDefaultDeletionError(volume_type_id=id) + elevated = context if context.is_admin else context.elevated() return db.volume_type_destroy(elevated, id) @@ -189,12 +200,18 @@ def get_volume_type_by_name(context, name): return db.volume_type_get_by_name(context, name) -def get_default_volume_type(): +def get_default_volume_type(contxt=None): """Get the default volume type. :raises VolumeTypeDefaultMisconfiguredError: when the configured default is not found """ + + if contxt: + project_default = db.project_default_volume_type_get( + contxt, contxt.project_id) + if project_default: + return get_volume_type(contxt, project_default.volume_type_id) name = CONF.default_volume_type ctxt = context.get_admin_context() vol_type = {} diff --git a/doc/source/cli/cli-manage-volumes.rst b/doc/source/cli/cli-manage-volumes.rst index f9b588ad43f..04c1f4cd918 100644 --- a/doc/source/cli/cli-manage-volumes.rst +++ b/doc/source/cli/cli-manage-volumes.rst @@ -99,11 +99,11 @@ volume creation. #. volume_type #. cinder_img_volume_type (via glance image metadata) -#. default_volume_type (via cinder.conf) +#. default volume type (via project defaults or cinder.conf) volume-type -+++++++++++ +^^^^^^^^^^^ User can specify `volume type` when creating a volume. @@ -122,7 +122,7 @@ User can specify `volume type` when creating a volume. cinder_img_volume_type -++++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^^^ If glance image has ``cinder_img_volume_type`` property, Cinder uses this parameter to specify ``volume type`` when creating a volume. @@ -198,11 +198,37 @@ a volume from the image. | user_id | 33fdc37314914796883706b33e587d51 | +---------------------+--------------------------------------+ -default_volume_type -+++++++++++++++++++ +default volume type +^^^^^^^^^^^^^^^^^^^ -If above parameters are not set, Cinder uses default_volume_type which is -defined in cinder.conf during volume creation. +If above parameters are not set, cinder uses default volume type during +volume creation. + +The effective default volume type (whether it be project default or +default_volume_type) can be checked with the following command: + +.. code-block:: console + + $ cinder type-default + +There are 2 ways to set the default volume type: + +1) Project specific defaults +2) default_volume_type defined in cinder.conf + +Project specific defaults (available since mv 3.62 or higher) +""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Project specific defaults can be managed using the `Default Volume Types API +`_ +It is set on a per project basis and has a higher priority over +default_volume_type defined in cinder.conf + +default_volume_type +""""""""""""""""""" + +If the project specific default is not set then default_volume_type +configured in cinder.conf is used to create volumes. Example cinder.conf file configuration. diff --git a/releasenotes/notes/project-default-types-3a14ad0d653e604e.yaml b/releasenotes/notes/project-default-types-3a14ad0d653e604e.yaml new file mode 100644 index 00000000000..1ebe47d3b03 --- /dev/null +++ b/releasenotes/notes/project-default-types-3a14ad0d653e604e.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Added support for project specific default volume types. + Microversion 3.62 of the Block Storage API introduces new + calls to set, get, and unset a default volume type for a + specific project. + Project specific defaults have higher priority than + the default_volume_type option in cinder.conf \ No newline at end of file