Merge "Default type overrides"

This commit is contained in:
Zuul 2020-09-16 21:49:13 +00:00 committed by Gerrit Code Review
commit fc7a263f99
42 changed files with 1505 additions and 37 deletions

View File

@ -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

View File

@ -16,6 +16,7 @@ Block Storage API V3 (CURRENT)
.. To create a volume, I might need a volume type, so list those next. .. To create a volume, I might need a volume type, so list those next.
.. include:: volumes-v3-types.inc .. include:: volumes-v3-types.inc
.. include:: volume-type-access.inc .. include:: volume-type-access.inc
.. include:: default-types.inc
.. Now my primary focus is on volumes and what I can do with them. .. Now my primary focus is on volumes and what I can do with them.
.. include:: volumes-v3-volumes.inc .. include:: volumes-v3-volumes.inc

View File

@ -168,6 +168,12 @@ volume_type_id:
in: path in: path
required: true required: true
type: string 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 # variables in query
action: action:

View File

@ -0,0 +1,6 @@
{
"default_type": {
"project_id": "6685584b-1eac-4da6-b5c3-555430cf68ff",
"volume_type_id": "40ec6e5e-c9bd-4170-8740-c1cd42d7eabb"
}
}

View File

@ -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"
}
]
}

View File

@ -0,0 +1,5 @@
{
"default_type": {
"volume_type": "lvm_backend"
}
}

View File

@ -0,0 +1,6 @@
{
"default_type": {
"project_id": "6685584b-1eac-4da6-b5c3-555430cf68ff",
"volume_type_id": "40ec6e5e-c9bd-4170-8740-c1cd42d7eabb"
}
}

View File

@ -22,7 +22,7 @@
"min_version": "3.0", "min_version": "3.0",
"status": "CURRENT", "status": "CURRENT",
"updated": "2018-07-17T00:00:00Z", "updated": "2018-07-17T00:00:00Z",
"version": "3.61" "version": "3.62"
} }
] ]
} }

View File

@ -46,7 +46,7 @@
"min_version": "3.0", "min_version": "3.0",
"status": "CURRENT", "status": "CURRENT",
"updated": "2018-07-17T00:00:00Z", "updated": "2018-07-17T00:00:00Z",
"version": "3.61" "version": "3.62"
} }
] ]
} }

View File

@ -163,6 +163,8 @@ VOLUME_TIME_COMPARISON_FILTER = '3.60'
VOLUME_CLUSTER_NAME = '3.61' VOLUME_CLUSTER_NAME = '3.61'
DEFAULT_TYPE_OVERRIDES = '3.62'
def get_mv_header(version): def get_mv_header(version):
"""Gets a formatted HTTP microversion header. """Gets a formatted HTTP microversion header.

View File

@ -141,6 +141,7 @@ REST_API_VERSION_HISTORY = """
("GET /v3/{project_id}/volumes/detail") requests. ("GET /v3/{project_id}/volumes/detail") requests.
* 3.61 - Add ``cluster_name`` attribute to response body of volume details * 3.61 - Add ``cluster_name`` attribute to response body of volume details
for admin. for admin.
* 3.62 - Default volume type overrides
""" """
# The minimum and maximum versions of the API supported # The minimum and maximum versions of the API supported
@ -148,9 +149,9 @@ REST_API_VERSION_HISTORY = """
# minimum version of the API supported. # minimum version of the API supported.
# Explicitly using /v2 endpoints will still work # Explicitly using /v2 endpoints will still work
_MIN_API_VERSION = "3.0" _MIN_API_VERSION = "3.0"
_MAX_API_VERSION = "3.61" _MAX_API_VERSION = "3.62"
_LEGACY_API_VERSION2 = "2.0" _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 # NOTE(cyeoh): min and max versions declared as functions so we can

View File

@ -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 Add ``cluster_name`` attribute to response body of volume details for admin in
Active/Active HA mode. 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.

View File

@ -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,
},
}
}

View File

@ -52,7 +52,7 @@ class VolumeTypesController(wsgi.Controller):
# get default volume type # get default volume type
if id is not None and id == 'default': 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: if not vol_type:
msg = _("Default volume type can not be found.") msg = _("Default volume type can not be found.")
raise exception.VolumeTypeNotFound(message=msg) raise exception.VolumeTypeNotFound(message=msg)

View File

@ -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())

View File

@ -27,6 +27,7 @@ from cinder.api.v3 import attachments
from cinder.api.v3 import backups from cinder.api.v3 import backups
from cinder.api.v3 import clusters from cinder.api.v3 import clusters
from cinder.api.v3 import consistencygroups 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_snapshots
from cinder.api.v3 import group_specs from cinder.api.v3 import group_specs
from cinder.api.v3 import group_types from cinder.api.v3 import group_types
@ -199,3 +200,24 @@ class APIRouter(cinder.api.openstack.APIRouter):
controller=self.resources['volume_transfers'], controller=self.resources['volume_transfers'],
collection={'detail': 'GET'}, collection={'detail': 'GET'},
member={'accept': 'POST'}) 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']})

View File

@ -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)}

View File

@ -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) 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)
#################### ####################

View File

@ -20,6 +20,7 @@
import collections import collections
from collections import abc from collections import abc
import contextlib
import datetime as dt import datetime as dt
import functools import functools
import itertools 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) 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 @require_admin_context
def group_type_access_remove(context, type_id, project_id): def group_type_access_remove(context, type_id, project_id):
"""Remove given tenant from the group type access list.""" """Remove given tenant from the group type access list."""

View File

@ -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

View File

@ -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): class QualityOfServiceSpecs(BASE, CinderBase):
"""Represents QoS specs as key/value pairs. """Represents QoS specs as key/value pairs.

View File

@ -392,7 +392,7 @@ class VolumeTypeDeletionError(Invalid):
class VolumeTypeDefaultDeletionError(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.") "type and cannot be deleted.")
@ -401,6 +401,10 @@ class VolumeTypeDefaultMisconfiguredError(CinderException):
"%(volume_type_name)s cannot be found.") "%(volume_type_name)s cannot be found.")
class VolumeTypeProjectDefaultNotFound(NotFound):
message = _("Default type for project %(project_id)s not found.")
class GroupTypeNotFound(NotFound): class GroupTypeNotFound(NotFound):
message = _("Group type %(group_type_id)s could not be found.") message = _("Group type %(group_type_id)s could not be found.")

View File

@ -21,6 +21,7 @@ from cinder.policies import backups
from cinder.policies import base from cinder.policies import base
from cinder.policies import capabilities from cinder.policies import capabilities
from cinder.policies import clusters from cinder.policies import clusters
from cinder.policies import default_types
from cinder.policies import group_actions from cinder.policies import group_actions
from cinder.policies import group_snapshot_actions from cinder.policies import group_snapshot_actions
from cinder.policies import group_snapshots from cinder.policies import group_snapshots
@ -83,4 +84,5 @@ def list_rules():
volume_metadata.list_rules(), volume_metadata.list_rules(),
type_extra_specs.list_rules(), type_extra_specs.list_rules(),
volumes.list_rules(), volumes.list_rules(),
default_types.list_rules(),
) )

View File

@ -18,6 +18,10 @@ from oslo_policy import policy
RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner' RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner'
RULE_ADMIN_API = 'rule:admin_api' 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 = [ rules = [
policy.RuleDefault('context_is_admin', 'role:admin', policy.RuleDefault('context_is_admin', 'role:admin',
description="Decides what is required for the " description="Decides what is required for the "
@ -30,6 +34,12 @@ rules = [
'is_admin:True or (role:admin and ' 'is_admin:True or (role:admin and '
'is_admin_project:True)', 'is_admin_project:True)',
description="Default rule for most Admin APIs."), 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."),
] ]

View File

@ -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

View File

@ -155,20 +155,20 @@ def authorize(context, action, target, do_raise=True, exc=None):
do_raise is False. do_raise is False.
""" """
init() init()
credentials = context.to_policy_values()
if not exc: if not exc:
exc = exception.PolicyNotAuthorized exc = exception.PolicyNotAuthorized
try: try:
result = _ENFORCER.authorize(action, target, credentials, result = _ENFORCER.authorize(action, target, context,
do_raise=do_raise, exc=exc, action=action) do_raise=do_raise, exc=exc, action=action)
except policy.PolicyNotRegistered: except policy.PolicyNotRegistered:
with excutils.save_and_reraise_exception(): with excutils.save_and_reraise_exception():
LOG.exception('Policy not registered') LOG.exception('Policy not registered')
except Exception: except Exception:
with excutils.save_and_reraise_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', '%(credentials)s',
{'action': action, 'credentials': credentials}) {'action': action,
'credentials': context.to_policy_values()})
return result return result

View File

@ -36,8 +36,10 @@ class GenericProjectInfo(object):
project_parent_id=None, project_parent_id=None,
project_subtree=None, project_subtree=None,
project_parent_tree=None, project_parent_tree=None,
is_admin_project=False): is_admin_project=False,
domain_id=None):
self.id = project_id self.id = project_id
self.domain_id = domain_id
self.keystone_api_version = project_keystone_api_version self.keystone_api_version = project_keystone_api_version
self.parent_id = project_parent_id self.parent_id = project_parent_id
self.subtree = project_subtree 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) parents_as_ids=parents_as_ids)
generic_project.parent_id = None generic_project.parent_id = None
generic_project.domain_id = project.domain_id
if project.parent_id != project.domain_id: if project.parent_id != project.domain_id:
generic_project.parent_id = project.parent_id generic_project.parent_id = project.parent_id

View File

@ -56,6 +56,10 @@ class OpenStackApiException400(OpenStackApiException):
message = _("400 Bad Request") message = _("400 Bad Request")
class OpenStackApiException403(OpenStackApiException):
message = _("403 Forbidden")
class OpenStackApiException500(OpenStackApiException): class OpenStackApiException500(OpenStackApiException):
message = _("500 Internal Server Error") message = _("500 Internal Server Error")
@ -129,11 +133,15 @@ class TestOpenStackClient(object):
self._authenticate(True) self._authenticate(True)
def api_request(self, relative_uri, check_response_status=None, 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() auth_result = self._authenticate()
# NOTE(justinsb): httplib 'helpfully' converts headers to lower case if base_url:
base_uri = auth_result['x-server-management-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: if strip_version:
# cut out version number and tenant_id # cut out version number and tenant_id
@ -169,12 +177,12 @@ class TestOpenStackClient(object):
else: else:
return "" 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]) 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) 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' kwargs['method'] = 'POST'
if body: if body:
headers = kwargs.setdefault('headers', {}) headers = kwargs.setdefault('headers', {})
@ -183,10 +191,10 @@ class TestOpenStackClient(object):
kwargs.setdefault('check_response_status', [http_client.OK, kwargs.setdefault('check_response_status', [http_client.OK,
http_client.ACCEPTED]) 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) 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' kwargs['method'] = 'PUT'
if body: if body:
headers = kwargs.setdefault('headers', {}) headers = kwargs.setdefault('headers', {})
@ -196,15 +204,15 @@ class TestOpenStackClient(object):
kwargs.setdefault('check_response_status', [http_client.OK, kwargs.setdefault('check_response_status', [http_client.OK,
http_client.ACCEPTED, http_client.ACCEPTED,
http_client.NO_CONTENT]) 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) 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['method'] = 'DELETE'
kwargs.setdefault('check_response_status', [http_client.OK, kwargs.setdefault('check_response_status', [http_client.OK,
http_client.ACCEPTED, http_client.ACCEPTED,
http_client.NO_CONTENT]) 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): def get_volume(self, volume_id):
return self.api_get('/volumes/%s' % volume_id)['volume'] 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): def list_group_replication_targets(self, group_id, params):
return self.api_post('/groups/%s/action' % 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)

View File

@ -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'])

View File

@ -129,11 +129,13 @@ class HTTPRequest(webob.Request):
kwargs['base_url'] = 'http://localhost/v3' kwargs['base_url'] = 'http://localhost/v3'
use_admin_context = kwargs.pop('use_admin_context', False) use_admin_context = kwargs.pop('use_admin_context', False)
version = kwargs.pop('version', api_version._MIN_API_VERSION) version = kwargs.pop('version', api_version._MIN_API_VERSION)
system_scope = kwargs.pop('system_scope', None)
out = os_wsgi.Request.blank(*args, **kwargs) out = os_wsgi.Request.blank(*args, **kwargs)
out.environ['cinder.context'] = FakeRequestContext( out.environ['cinder.context'] = FakeRequestContext(
fake.USER_ID, fake.USER_ID,
fake.PROJECT_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) out.api_version_request = api_version.APIVersionRequest(version)
return out return out

View File

@ -74,7 +74,7 @@ def return_volume_types_get_volume_type(context, id):
return fake_volume_type(id) return fake_volume_type(id)
def return_volume_types_get_default(): def return_volume_types_get_default(context):
return fake_volume_type(1) return fake_volume_type(1)

View File

@ -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)

View File

@ -13,10 +13,13 @@
from cinder.api import microversions as mv from cinder.api import microversions as mv
from cinder.api.v2 import types from cinder.api.v2 import types
from cinder import context from cinder import context
from cinder import db
from cinder import exception
from cinder import objects from cinder import objects
from cinder.tests.unit.api import fakes from cinder.tests.unit.api import fakes
from cinder.tests.unit import fake_constants as fake from cinder.tests.unit import fake_constants as fake
from cinder.tests.unit import test from cinder.tests.unit import test
from cinder.volume import volume_types
class VolumeTypesApiTest(test.TestCase): class VolumeTypesApiTest(test.TestCase):
@ -89,3 +92,19 @@ class VolumeTypesApiTest(test.TestCase):
self.assertEqual( self.assertEqual(
['volume_type1', 'volume_type2'], ['volume_type1', 'volume_type2'],
sorted([az['name'] for az in res_dict['volume_types']])) 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'])

View File

@ -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)

View File

@ -42,6 +42,7 @@ OBJECT3_ID = '7bf5ffa9-18a2-4b64-aab4-0798b53ee4e7'
PROJECT_ID = '89afd400-b646-4bbc-b12b-c0a4d63e5bd3' PROJECT_ID = '89afd400-b646-4bbc-b12b-c0a4d63e5bd3'
PROJECT2_ID = '452ebfbc-55d9-402a-87af-65061916c24b' PROJECT2_ID = '452ebfbc-55d9-402a-87af-65061916c24b'
PROJECT3_ID = 'f6c912d7-bf30-4b12-af81-a9e0b2f85f85' PROJECT3_ID = 'f6c912d7-bf30-4b12-af81-a9e0b2f85f85'
DOMAIN_ID = 'e747b880-4565-4d18-b8e2-310bdec83759'
PROVIDER_ID = '60087173-e899-470a-9e3a-ba4cffa3e3e3' PROVIDER_ID = '60087173-e899-470a-9e3a-ba4cffa3e3e3'
PROVIDER2_ID = '1060eccd-64bb-4ed2-86ce-aeaf135a97b8' PROVIDER2_ID = '1060eccd-64bb-4ed2-86ce-aeaf135a97b8'
PROVIDER3_ID = '63736819-1c95-440e-a873-b9d685afede5' PROVIDER3_ID = '63736819-1c95-440e-a873-b9d685afede5'

View File

@ -34,6 +34,10 @@ class CinderPolicyTests(test.TestCase):
user_id=fake_constants.USER_ID, project_id=self.project_id, user_id=fake_constants.USER_ID, project_id=self.project_id,
roles=['admin'] 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( self.user_context = cinder_context.RequestContext(
user_id=fake_constants.USER2_ID, project_id=self.project_id, user_id=fake_constants.USER2_ID, project_id=self.project_id,
roles=['non-admin'] roles=['non-admin']
@ -42,6 +46,9 @@ class CinderPolicyTests(test.TestCase):
user_id=fake_constants.USER3_ID, project_id=self.other_project_id, user_id=fake_constants.USER3_ID, project_id=self.other_project_id,
roles=['non-admin'] 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) fake_image.mock_image_service(self)
def _get_request_response(self, context, path, method, body=None, def _get_request_response(self, context, path, method, body=None,

View File

@ -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)

View File

@ -72,7 +72,7 @@ class QuotaUtilsTest(test.TestCase):
del returned_project.subtree del returned_project.subtree
keystoneclient.projects.get.return_value = returned_project keystoneclient.projects.get.return_value = returned_project
expected_project = quota_utils.GenericProjectInfo( 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( project = quota_utils.get_project_hierarchy(
self.context, self.context.project_id) self.context, self.context.project_id)
self.assertEqual(expected_project.__dict__, project.__dict__) self.assertEqual(expected_project.__dict__, project.__dict__)
@ -86,7 +86,8 @@ class QuotaUtilsTest(test.TestCase):
returned_project.subtree = subtree_dict returned_project.subtree = subtree_dict
keystoneclient.projects.get.return_value = returned_project keystoneclient.projects.get.return_value = returned_project
expected_project = quota_utils.GenericProjectInfo( 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( project = quota_utils.get_project_hierarchy(
self.context, self.context.project_id, subtree_as_ids=True) self.context, self.context.project_id, subtree_as_ids=True)
keystoneclient.projects.get.assert_called_once_with( keystoneclient.projects.get.assert_called_once_with(

View File

@ -379,7 +379,7 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask):
raise raise
# otherwise, use the default volume type # 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, def execute(self, context, size, snapshot, image_id, source_volume,
availability_zone, volume_type, metadata, key_manager, availability_zone, volume_type, metadata, key_manager,

View File

@ -116,17 +116,28 @@ def destroy(context, id):
if id is None: if id is None:
msg = _("id cannot be None") msg = _("id cannot be None")
raise exception.InvalidVolumeType(reason=msg) 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. # Default type *must* be set in order to delete any volume type.
# If the default isn't set, the following call will raise # If the default isn't set, the following call will raise
# VolumeTypeDefaultMisconfiguredError exception which will error out the # VolumeTypeDefaultMisconfiguredError exception which will error out the
# delete operation. # delete operation.
default_type = get_default_volume_type() 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'): if id == default_type.get('id'):
raise exception.VolumeTypeDefaultDeletionError(volume_type_id=id) raise exception.VolumeTypeDefaultDeletionError(volume_type_id=id)
elevated = context if context.is_admin else context.elevated()
return db.volume_type_destroy(elevated, id) 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) 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. """Get the default volume type.
:raises VolumeTypeDefaultMisconfiguredError: when the configured default :raises VolumeTypeDefaultMisconfiguredError: when the configured default
is not found 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 name = CONF.default_volume_type
ctxt = context.get_admin_context() ctxt = context.get_admin_context()
vol_type = {} vol_type = {}

View File

@ -99,11 +99,11 @@ volume creation.
#. volume_type #. volume_type
#. cinder_img_volume_type (via glance image metadata) #. 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 volume-type
+++++++++++ ^^^^^^^^^^^
User can specify `volume type` when creating a volume. 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 cinder_img_volume_type
++++++++++++++++++++++ ^^^^^^^^^^^^^^^^^^^^^^
If glance image has ``cinder_img_volume_type`` property, Cinder uses this If glance image has ``cinder_img_volume_type`` property, Cinder uses this
parameter to specify ``volume type`` when creating a volume. parameter to specify ``volume type`` when creating a volume.
@ -198,11 +198,37 @@ a volume from the image.
| user_id | 33fdc37314914796883706b33e587d51 | | user_id | 33fdc37314914796883706b33e587d51 |
+---------------------+--------------------------------------+ +---------------------+--------------------------------------+
default_volume_type default volume type
+++++++++++++++++++ ^^^^^^^^^^^^^^^^^^^
If above parameters are not set, Cinder uses default_volume_type which is If above parameters are not set, cinder uses default volume type during
defined in cinder.conf during volume creation. 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
<https://docs.openstack.org/api-ref/block-storage/v3/#default-volume-types-default-types>`_
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. Example cinder.conf file configuration.

View File

@ -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