Browse Source

Merge "Default type overrides"

changes/46/740146/6
Zuul 1 week ago
committed by Gerrit Code Review
parent
commit
fc7a263f99
42 changed files with 1504 additions and 36 deletions
  1. +167
    -0
      api-ref/source/v3/default-types.inc
  2. +1
    -0
      api-ref/source/v3/index.rst
  3. +6
    -0
      api-ref/source/v3/parameters.yaml
  4. +6
    -0
      api-ref/source/v3/samples/get-default-type-response.json
  5. +12
    -0
      api-ref/source/v3/samples/get-default-types-response.json
  6. +5
    -0
      api-ref/source/v3/samples/set-default-type-request.json
  7. +6
    -0
      api-ref/source/v3/samples/set-default-type-response.json
  8. +1
    -1
      api-ref/source/v3/samples/versions/version-show-response.json
  9. +1
    -1
      api-ref/source/v3/samples/versions/versions-response.json
  10. +2
    -0
      cinder/api/microversions.py
  11. +3
    -2
      cinder/api/openstack/api_version_request.py
  12. +6
    -0
      cinder/api/openstack/rest_api_version_history.rst
  13. +34
    -0
      cinder/api/schemas/default_types.py
  14. +1
    -1
      cinder/api/v2/types.py
  15. +127
    -0
      cinder/api/v3/default_types.py
  16. +22
    -0
      cinder/api/v3/router.py
  17. +68
    -0
      cinder/api/v3/views/default_types.py
  18. +21
    -0
      cinder/db/api.py
  19. +60
    -0
      cinder/db/sqlalchemy/api.py
  20. +45
    -0
      cinder/db/sqlalchemy/migrate_repo/versions/140_create_project_default_volume_type.py
  21. +12
    -0
      cinder/db/sqlalchemy/models.py
  22. +5
    -1
      cinder/exception.py
  23. +2
    -0
      cinder/policies/__init__.py
  24. +10
    -0
      cinder/policies/base.py
  25. +76
    -0
      cinder/policies/default_types.py
  26. +4
    -4
      cinder/policy.py
  27. +4
    -1
      cinder/quota_utils.py
  28. +35
    -11
      cinder/tests/functional/api/client.py
  29. +124
    -0
      cinder/tests/functional/test_default_types.py
  30. +3
    -1
      cinder/tests/unit/api/fakes.py
  31. +1
    -1
      cinder/tests/unit/api/v2/test_types.py
  32. +227
    -0
      cinder/tests/unit/api/v3/test_default_types.py
  33. +19
    -0
      cinder/tests/unit/api/v3/test_types.py
  34. +112
    -0
      cinder/tests/unit/db/test_default_types.py
  35. +1
    -0
      cinder/tests/unit/fake_constants.py
  36. +7
    -0
      cinder/tests/unit/policies/test_base.py
  37. +203
    -0
      cinder/tests/unit/policies/test_default_volume_types.py
  38. +3
    -2
      cinder/tests/unit/test_quota_utils.py
  39. +1
    -1
      cinder/volume/flows/api/create_volume.py
  40. +20
    -3
      cinder/volume/volume_types.py
  41. +32
    -6
      doc/source/cli/cli-manage-volumes.rst
  42. +9
    -0
      releasenotes/notes/project-default-types-3a14ad0d653e604e.yaml

+ 167
- 0
api-ref/source/v3/default-types.inc 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

+ 1
- 0
api-ref/source/v3/index.rst 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.
.. 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


+ 6
- 0
api-ref/source/v3/parameters.yaml View File

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


+ 6
- 0
api-ref/source/v3/samples/get-default-type-response.json View File

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

+ 12
- 0
api-ref/source/v3/samples/get-default-types-response.json 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"
}
]
}

+ 5
- 0
api-ref/source/v3/samples/set-default-type-request.json View File

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

+ 6
- 0
api-ref/source/v3/samples/set-default-type-response.json View File

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

+ 1
- 1
api-ref/source/v3/samples/versions/version-show-response.json View File

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

+ 1
- 1
api-ref/source/v3/samples/versions/versions-response.json View File

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

+ 2
- 0
cinder/api/microversions.py View File

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


+ 3
- 2
cinder/api/openstack/api_version_request.py View File

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


+ 6
- 0
cinder/api/openstack/rest_api_version_history.rst 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
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.

+ 34
- 0
cinder/api/schemas/default_types.py 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,
},
}
}

+ 1
- 1
cinder/api/v2/types.py View File

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


+ 127
- 0
cinder/api/v3/default_types.py 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())

+ 22
- 0
cinder/api/v3/router.py View File

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

+ 68
- 0
cinder/api/v3/views/default_types.py 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)}

+ 21
- 0
cinder/db/api.py 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)


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)


####################




+ 60
- 0
cinder/db/sqlalchemy/api.py View File

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


+ 45
- 0
cinder/db/sqlalchemy/migrate_repo/versions/140_create_project_default_volume_type.py 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

+ 12
- 0
cinder/db/sqlalchemy/models.py 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):
"""Represents QoS specs as key/value pairs.



+ 5
- 1
cinder/exception.py View File

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



+ 2
- 0
cinder/policies/__init__.py View File

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

+ 10
- 0
cinder/policies/base.py View File

@@ -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."),
]




+ 76
- 0
cinder/policies/default_types.py 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

+ 4
- 4
cinder/policy.py View File

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




+ 4
- 1
cinder/quota_utils.py View File

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



+ 35
- 11
cinder/tests/functional/api/client.py View File

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

+ 124
- 0
cinder/tests/functional/test_default_types.py 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'])

+ 3
- 1
cinder/tests/unit/api/fakes.py View File

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



+ 1
- 1
cinder/tests/unit/api/v2/test_types.py View File

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




+ 227
- 0
cinder/tests/unit/api/v3/test_default_types.py 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)

+ 19
- 0
cinder/tests/unit/api/v3/test_types.py View File

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

+ 112
- 0
cinder/tests/unit/db/test_default_types.py 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)

+ 1
- 0
cinder/tests/unit/fake_constants.py View File

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


+ 7
- 0
cinder/tests/unit/policies/test_base.py View File

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


+ 203
- 0
cinder/tests/unit/policies/test_default_volume_types.py 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)

+ 3
- 2
cinder/tests/unit/test_quota_utils.py View File

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


+ 1
- 1
cinder/volume/flows/api/create_volume.py View File

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


+ 20
- 3
cinder/volume/volume_types.py View File

@@ -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 = {}


+ 32
- 6
doc/source/cli/cli-manage-volumes.rst View File

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

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
<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 above parameters are not set, Cinder uses default_volume_type which is
defined in cinder.conf during volume creation.
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.



+ 9
- 0
releasenotes/notes/project-default-types-3a14ad0d653e604e.yaml 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

Loading…
Cancel
Save