Volume type access extension

This extension adds the ability to manage volume type access:
* Volume types are public by default
* Private volume types can be created by setting
  the is_public boolean field to False at creation time.
* Access to a private volume type can be controlled
  by adding or removing a project from it.
* Private volume types without projects are only visible
  by users with the admin role/context.

Implementation details and unit tests were mostly adapted
from Nova flavor access extension.

DocImpact: New volume type access extension
Implements: blueprint private-volume-types
Change-Id: I8faf1d8097bf8412d4e169ec3503821351795561
This commit is contained in:
Mathieu Gagné 2014-06-25 20:22:12 -04:00
parent 7e3ddf8d0d
commit 6c0f50b1ec
23 changed files with 1000 additions and 46 deletions

View File

@ -52,13 +52,15 @@ class VolumeTypesManageController(wsgi.Controller):
vol_type = body['volume_type']
name = vol_type.get('name', None)
specs = vol_type.get('extra_specs', {})
is_public = vol_type.get('os-volume-type-access:is_public', True)
if name is None or name == "":
raise webob.exc.HTTPBadRequest()
try:
volume_types.create(context, name, specs)
volume_types.create(context, name, specs, is_public)
vol_type = volume_types.get_volume_type_by_name(context, name)
req.cache_resource(vol_type, name='types')
notifier_info = dict(volume_types=vol_type)
rpc.get_notifier('volumeType').info(context, 'volume_type.create',
notifier_info)

View File

@ -0,0 +1,215 @@
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""The volume type access extension."""
import six
import webob
from cinder.api import extensions
from cinder.api.openstack import wsgi
from cinder.api import xmlutil
from cinder import exception
from cinder.i18n import _
from cinder.openstack.common import uuidutils
from cinder.volume import volume_types
soft_authorize = extensions.soft_extension_authorizer('volume',
'volume_type_access')
authorize = extensions.extension_authorizer('volume', 'volume_type_access')
def make_volume_type(elem):
elem.set('{%s}is_public' % Volume_type_access.namespace,
'%s:is_public' % Volume_type_access.alias)
def make_volume_type_access(elem):
elem.set('volume_type_id')
elem.set('project_id')
class VolumeTypeTemplate(xmlutil.TemplateBuilder):
def construct(self):
root = xmlutil.TemplateElement('volume_type', selector='volume_type')
make_volume_type(root)
alias = Volume_type_access.alias
namespace = Volume_type_access.namespace
return xmlutil.SlaveTemplate(root, 1, nsmap={alias: namespace})
class VolumeTypesTemplate(xmlutil.TemplateBuilder):
def construct(self):
root = xmlutil.TemplateElement('volume_types')
elem = xmlutil.SubTemplateElement(
root, 'volume_type', selector='volume_types')
make_volume_type(elem)
alias = Volume_type_access.alias
namespace = Volume_type_access.namespace
return xmlutil.SlaveTemplate(root, 1, nsmap={alias: namespace})
class VolumeTypeAccessTemplate(xmlutil.TemplateBuilder):
def construct(self):
root = xmlutil.TemplateElement('volume_type_access')
elem = xmlutil.SubTemplateElement(root, 'access',
selector='volume_type_access')
make_volume_type_access(elem)
return xmlutil.MasterTemplate(root, 1)
def _marshall_volume_type_access(vol_type):
rval = []
for project_id in vol_type['projects']:
rval.append({'volume_type_id': vol_type['id'],
'project_id': project_id})
return {'volume_type_access': rval}
class VolumeTypeAccessController(object):
"""The volume type access API controller for the OpenStack API."""
def __init__(self):
super(VolumeTypeAccessController, self).__init__()
@wsgi.serializers(xml=VolumeTypeAccessTemplate)
def index(self, req, type_id):
context = req.environ['cinder.context']
authorize(context)
try:
vol_type = volume_types.get_volume_type(
context, type_id, expected_fields=['projects'])
except exception.VolumeTypeNotFound:
explanation = _("Volume type not found.")
raise webob.exc.HTTPNotFound(explanation=explanation)
if vol_type['is_public']:
expl = _("Access list not available for public volume types.")
raise webob.exc.HTTPNotFound(explanation=expl)
return _marshall_volume_type_access(vol_type)
class VolumeTypeActionController(wsgi.Controller):
"""The volume type access API controller for the OpenStack API."""
def _check_body(self, body, action_name):
if not self.is_valid_body(body, action_name):
raise webob.exc.HTTPBadRequest()
access = body[action_name]
project = access.get('project')
if not uuidutils.is_uuid_like(project):
msg = _("Bad project format: "
"project is not in proper format (%s)") % project
raise webob.exc.HTTPBadRequest(explanation=msg)
def _extend_vol_type(self, vol_type_rval, vol_type_ref):
key = "%s:is_public" % (Volume_type_access.alias)
vol_type_rval[key] = vol_type_ref['is_public']
@wsgi.extends
def show(self, req, resp_obj, id):
context = req.environ['cinder.context']
if soft_authorize(context):
# Attach our slave template to the response object
resp_obj.attach(xml=VolumeTypeTemplate())
vol_type = req.cached_resource_by_id(id, name='types')
self._extend_vol_type(resp_obj.obj['volume_type'], vol_type)
@wsgi.extends
def index(self, req, resp_obj):
context = req.environ['cinder.context']
if soft_authorize(context):
# Attach our slave template to the response object
resp_obj.attach(xml=VolumeTypesTemplate())
for vol_type_rval in list(resp_obj.obj['volume_types']):
type_id = vol_type_rval['id']
vol_type = req.cached_resource_by_id(type_id, name='types')
self._extend_vol_type(vol_type_rval, vol_type)
@wsgi.extends
def detail(self, req, resp_obj):
context = req.environ['cinder.context']
if soft_authorize(context):
# Attach our slave template to the response object
resp_obj.attach(xml=VolumeTypesTemplate())
for vol_type_rval in list(resp_obj.obj['volume_types']):
type_id = vol_type_rval['id']
vol_type = req.cached_resource_by_id(type_id, name='types')
self._extend_vol_type(vol_type_rval, vol_type)
@wsgi.extends(action='create')
def create(self, req, body, resp_obj):
context = req.environ['cinder.context']
if soft_authorize(context):
# Attach our slave template to the response object
resp_obj.attach(xml=VolumeTypeTemplate())
type_id = resp_obj.obj['volume_type']['id']
vol_type = req.cached_resource_by_id(type_id, name='types')
self._extend_vol_type(resp_obj.obj['volume_type'], vol_type)
@wsgi.action('addProjectAccess')
def _addProjectAccess(self, req, id, body):
context = req.environ['cinder.context']
authorize(context, action="addProjectAccess")
self._check_body(body, 'addProjectAccess')
project = body['addProjectAccess']['project']
try:
volume_types.add_volume_type_access(context, id, project)
except exception.VolumeTypeAccessExists as err:
raise webob.exc.HTTPConflict(explanation=six.text_type(err))
except exception.VolumeTypeNotFound as err:
raise webob.exc.HTTPNotFound(explanation=six.text_type(err))
return webob.Response(status_int=202)
@wsgi.action('removeProjectAccess')
def _removeProjectAccess(self, req, id, body):
context = req.environ['cinder.context']
authorize(context, action="removeProjectAccess")
self._check_body(body, 'removeProjectAccess')
project = body['removeProjectAccess']['project']
try:
volume_types.remove_volume_type_access(context, id, project)
except (exception.VolumeTypeNotFound,
exception.VolumeTypeAccessNotFound) as err:
raise webob.exc.HTTPNotFound(explanation=six.text_type(err))
return webob.Response(status_int=202)
class Volume_type_access(extensions.ExtensionDescriptor):
"""Volume type access support."""
name = "VolumeTypeAccess"
alias = "os-volume-type-access"
namespace = ("http://docs.openstack.org/volume/"
"ext/os-volume-type-access/api/v1")
updated = "2014-06-26T00:00:00Z"
def get_resources(self):
resources = []
res = extensions.ResourceExtension(
Volume_type_access.alias,
VolumeTypeAccessController(),
parent=dict(member_name='type', collection_name='types'))
resources.append(res)
return resources
def get_controller_extensions(self):
controller = VolumeTypeActionController()
extension = extensions.ControllerExtension(self, 'types', controller)
return [extension]

View File

@ -376,12 +376,15 @@ def load_standard_extensions(ext_mgr, logger, path, package, ext_list=None):
def extension_authorizer(api_name, extension_name):
def authorize(context, target=None):
def authorize(context, target=None, action=None):
if target is None:
target = {'project_id': context.project_id,
'user_id': context.user_id}
action = '%s_extension:%s' % (api_name, extension_name)
cinder.policy.enforce(context, action, target)
if action is None:
act = '%s_extension:%s' % (api_name, extension_name)
else:
act = '%s_extension:%s:%s' % (api_name, extension_name, action)
cinder.policy.enforce(context, act, target)
return authorize

View File

@ -57,6 +57,7 @@ class VolumeTypesController(wsgi.Controller):
"""Returns the list of volume types."""
context = req.environ['cinder.context']
vol_types = volume_types.get_all_types(context).values()
req.cache_resource(vol_types, name='types')
return self._view_builder.index(req, vol_types)
@wsgi.serializers(xml=VolumeTypeTemplate)
@ -66,6 +67,7 @@ class VolumeTypesController(wsgi.Controller):
try:
vol_type = volume_types.get_volume_type(context, id)
req.cache_resource(vol_type, name='types')
except exception.NotFound:
raise exc.HTTPNotFound()

View File

@ -54,7 +54,8 @@ class APIRouter(cinder.api.openstack.APIRouter):
self.resources['types'] = types.create_resource()
mapper.resource("type", "types",
controller=self.resources['types'])
controller=self.resources['types'],
member={'action': 'POST'})
self.resources['snapshots'] = snapshots.create_resource(ext_mgr)
mapper.resource("snapshot", "snapshots",

View File

@ -15,6 +15,7 @@
"""The volume type & volume types extra specs extension."""
from oslo.utils import strutils
from webob import exc
from cinder.api.openstack import wsgi
@ -22,6 +23,7 @@ from cinder.api.views import types as views_types
from cinder.api import xmlutil
from cinder import exception
from cinder.i18n import _
from cinder import utils
from cinder.volume import volume_types
@ -56,9 +58,9 @@ class VolumeTypesController(wsgi.Controller):
@wsgi.serializers(xml=VolumeTypesTemplate)
def index(self, req):
"""Returns the list of volume types."""
context = req.environ['cinder.context']
vol_types = volume_types.get_all_types(context).values()
return self._view_builder.index(req, vol_types)
limited_types = self._get_volume_types(req)
req.cache_resource(limited_types, name='types')
return self._view_builder.index(req, limited_types)
@wsgi.serializers(xml=VolumeTypeTemplate)
def show(self, req, id):
@ -67,12 +69,47 @@ class VolumeTypesController(wsgi.Controller):
try:
vol_type = volume_types.get_volume_type(context, id)
req.cache_resource(vol_type, name='types')
except exception.NotFound:
msg = _("Volume type not found")
raise exc.HTTPNotFound(explanation=msg)
return self._view_builder.show(req, vol_type)
def _parse_is_public(self, is_public):
"""Parse is_public into something usable.
* True: List public volume types only
* False: List private volume types only
* None: List both public and private volume types
"""
if is_public is None:
# preserve default value of showing only public types
return True
elif utils.is_none_string(is_public):
return None
else:
try:
return strutils.bool_from_string(is_public, strict=True)
except ValueError:
msg = _('Invalid is_public filter [%s]') % is_public
raise exc.HTTPBadRequest(explanation=msg)
def _get_volume_types(self, req):
"""Helper function that returns a list of type dicts."""
filters = {}
context = req.environ['cinder.context']
if context.is_admin:
# Only admin has query access to all volume types
filters['is_public'] = self._parse_is_public(
req.params.get('is_public', None))
else:
filters['is_public'] = True
limited_types = volume_types.get_all_types(
context, search_opts=filters).values()
return limited_types
def create_resource():
return wsgi.Resource(VolumeTypesController())

View File

@ -369,19 +369,41 @@ def volume_admin_metadata_update(context, volume_id, metadata, delete):
##################
def volume_type_create(context, values):
def volume_type_create(context, values, projects=None):
"""Create a new volume type."""
return IMPL.volume_type_create(context, values)
return IMPL.volume_type_create(context, values, projects)
def volume_type_get_all(context, inactive=False):
"""Get all volume types."""
return IMPL.volume_type_get_all(context, inactive)
def volume_type_get_all(context, inactive=False, filters=None):
"""Get all volume types.
:param context: context to query under
:param inactive: Include inactive volume types to the result set
:param filters: Filters for the query in the form of key/value.
:is_public: Filter volume types based on visibility:
* **True**: List public volume types only
* **False**: List private volume types only
* **None**: List both public and private volume types
:returns: list of matching volume types
"""
return IMPL.volume_type_get_all(context, inactive, filters)
def volume_type_get(context, id, inactive=False):
"""Get volume type by id."""
return IMPL.volume_type_get(context, id, inactive)
def volume_type_get(context, id, inactive=False, expected_fields=None):
"""Get volume type by id.
:param context: context to query under
:param id: Volume type id to get.
:param inactive: Consider inactive volume types when searching
:param expected_fields: Return those additional fields.
Supported fields are: projects.
:returns: volume type
"""
return IMPL.volume_type_get(context, id, inactive, expected_fields)
def volume_type_get_by_name(context, name):
@ -435,6 +457,21 @@ def volume_get_active_by_window(context, begin, end=None, project_id=None):
return IMPL.volume_get_active_by_window(context, begin, end, project_id)
def volume_type_access_get_all(context, type_id):
"""Get all volume type access of a volume type."""
return IMPL.volume_type_access_get_all(context, type_id)
def volume_type_access_add(context, type_id, project_id):
"""Add volume type access for project."""
return IMPL.volume_type_access_add(context, type_id, project_id)
def volume_type_access_remove(context, type_id, project_id):
"""Remove volume type access for project."""
return IMPL.volume_type_access_remove(context, type_id, project_id)
####################

View File

@ -37,6 +37,7 @@ from sqlalchemy import or_
from sqlalchemy.orm import joinedload, joinedload_all
from sqlalchemy.orm import RelationshipProperty
from sqlalchemy.sql.expression import literal_column
from sqlalchemy.sql.expression import true
from sqlalchemy.sql import func
from cinder.common import sqlalchemyutils
@ -1871,8 +1872,8 @@ def snapshot_metadata_update(context, snapshot_id, metadata, delete):
@require_admin_context
def volume_type_create(context, values):
"""Create a new instance type.
def volume_type_create(context, values, projects=None):
"""Create a new volume type.
In order to pass in extra specs, the values dict should contain a
'extra_specs' key/value pair:
@ -1881,6 +1882,8 @@ def volume_type_create(context, values):
if not values.get('id'):
values['id'] = str(uuid.uuid4())
projects = projects or []
session = get_session()
with session.begin():
try:
@ -1901,20 +1904,59 @@ def volume_type_create(context, values):
session.add(volume_type_ref)
except Exception as e:
raise db_exc.DBError(e)
for project in set(projects):
access_ref = models.VolumeTypeProjects()
access_ref.update({"volume_type_id": volume_type_ref.id,
"project_id": project})
access_ref.save(session=session)
return volume_type_ref
def _volume_type_get_query(context, session=None, read_deleted=None,
expected_fields=None):
expected_fields = expected_fields or []
query = model_query(context,
models.VolumeTypes,
session=session,
read_deleted=read_deleted).\
options(joinedload('extra_specs'))
if 'projects' in expected_fields:
query = query.options(joinedload('projects'))
if not context.is_admin:
the_filter = [models.VolumeTypes.is_public == true()]
projects_attr = getattr(models.VolumeTypes, 'projects')
the_filter.extend([
projects_attr.any(project_id=context.project_id)
])
query = query.filter(or_(*the_filter))
return query
@require_context
def volume_type_get_all(context, inactive=False, filters=None):
"""Returns a dict describing all volume_types with name as key."""
filters = filters or {}
read_deleted = "yes" if inactive else "no"
rows = model_query(context, models.VolumeTypes,
read_deleted=read_deleted).\
options(joinedload('extra_specs')).\
order_by("name").\
all()
query = _volume_type_get_query(context, read_deleted=read_deleted)
if 'is_public' in filters and filters['is_public'] is not None:
the_filter = [models.VolumeTypes.is_public == filters['is_public']]
if filters['is_public'] and context.project_id is not None:
projects_attr = getattr(models.VolumeTypes, 'projects')
the_filter.extend([
projects_attr.any(project_id=context.project_id, deleted=False)
])
if len(the_filter) > 1:
query = query.filter(or_(*the_filter))
else:
query = query.filter(the_filter[0])
rows = query.order_by("name").all()
result = {}
for row in rows:
@ -1923,28 +1965,50 @@ def volume_type_get_all(context, inactive=False, filters=None):
return result
def _volume_type_get_id_from_volume_type_query(context, id, session=None):
return model_query(
context, models.VolumeTypes.id, read_deleted="no",
session=session, base_model=models.VolumeTypes).\
filter_by(id=id)
def _volume_type_get_id_from_volume_type(context, id, session=None):
result = _volume_type_get_id_from_volume_type_query(
context, id, session=session).first()
if not result:
raise exception.VolumeTypeNotFound(volume_type_id=id)
return result[0]
@require_context
def _volume_type_get(context, id, session=None, inactive=False):
def _volume_type_get(context, id, session=None, inactive=False,
expected_fields=None):
expected_fields = expected_fields or []
read_deleted = "yes" if inactive else "no"
result = model_query(context,
models.VolumeTypes,
session=session,
read_deleted=read_deleted).\
options(joinedload('extra_specs')).\
result = _volume_type_get_query(
context, session, read_deleted, expected_fields).\
filter_by(id=id).\
first()
if not result:
raise exception.VolumeTypeNotFound(volume_type_id=id)
return _dict_with_extra_specs(result)
vtype = _dict_with_extra_specs(result)
if 'projects' in expected_fields:
vtype['projects'] = [p['project_id'] for p in result['projects']]
return vtype
@require_context
def volume_type_get(context, id, inactive=False):
def volume_type_get(context, id, inactive=False, expected_fields=None):
"""Return a dict describing specific volume_type."""
return _volume_type_get(context, id, None, inactive)
return _volume_type_get(context, id,
session=None,
inactive=inactive,
expected_fields=expected_fields)
@require_context
@ -1956,8 +2020,8 @@ def _volume_type_get_by_name(context, name, session=None):
if not result:
raise exception.VolumeTypeNotFoundByName(volume_type_name=name)
else:
return _dict_with_extra_specs(result)
return _dict_with_extra_specs(result)
@require_context
@ -2107,6 +2171,51 @@ def volume_get_active_by_window(context,
return query.all()
def _volume_type_access_query(context, session=None):
return model_query(context, models.VolumeTypeProjects, session=session,
read_deleted="no")
@require_admin_context
def volume_type_access_get_all(context, type_id):
volume_type_id = _volume_type_get_id_from_volume_type(context, type_id)
return _volume_type_access_query(context).\
filter_by(volume_type_id=volume_type_id).all()
@require_admin_context
def volume_type_access_add(context, type_id, project_id):
"""Add given tenant to the volume type access list."""
volume_type_id = _volume_type_get_id_from_volume_type(context, type_id)
access_ref = models.VolumeTypeProjects()
access_ref.update({"volume_type_id": volume_type_id,
"project_id": project_id})
session = get_session()
with session.begin():
try:
access_ref.save(session=session)
except db_exc.DBDuplicateEntry:
raise exception.VolumeTypeAccessExists(volume_type_id=type_id,
project_id=project_id)
return access_ref
@require_admin_context
def volume_type_access_remove(context, type_id, project_id):
"""Remove given tenant from the volume type access list."""
volume_type_id = _volume_type_get_id_from_volume_type(context, type_id)
count = _volume_type_access_query(context).\
filter_by(volume_type_id=volume_type_id).\
filter_by(project_id=project_id).\
soft_delete(synchronize_session=False)
if count == 0:
raise exception.VolumeTypeAccessNotFound(
volume_type_id=type_id, project_id=project_id)
####################

View File

@ -0,0 +1,74 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from sqlalchemy import Boolean, Column, DateTime, UniqueConstraint
from sqlalchemy import Integer, MetaData, String, Table, ForeignKey
from cinder.i18n import _
from cinder.openstack.common import log as logging
LOG = logging.getLogger(__name__)
def upgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
volume_types = Table('volume_types', meta, autoload=True)
is_public = Column('is_public', Boolean)
try:
volume_types.create_column(is_public)
# pylint: disable=E1120
volume_types.update().values(is_public=True).execute()
except Exception:
LOG.error(_("Column |%s| not created!"), repr(is_public))
raise
volume_type_projects = Table(
'volume_type_projects', meta,
Column('id', Integer, primary_key=True, nullable=False),
Column('created_at', DateTime),
Column('updated_at', DateTime),
Column('deleted_at', DateTime),
Column('volume_type_id', String(36),
ForeignKey('volume_types.id')),
Column('project_id', String(length=255)),
Column('deleted', Boolean(create_constraint=True, name=None)),
UniqueConstraint('volume_type_id', 'project_id', 'deleted'),
mysql_engine='InnoDB',
)
try:
volume_type_projects.create()
except Exception:
LOG.error(_("Table |%s| not created!"), repr(volume_type_projects))
raise
def downgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
volume_types = Table('volume_types', meta, autoload=True)
is_public = volume_types.columns.is_public
try:
volume_types.drop_column(is_public)
except Exception:
LOG.error(_("volume_types.is_public column not dropped"))
raise
volume_type_projects = Table('volume_type_projects', meta, autoload=True)
try:
volume_type_projects.drop()
except Exception:
LOG.error(_("volume_type_projects table not dropped"))
raise

View File

@ -0,0 +1,29 @@
-- As sqlite does not support the DROP CHECK, we need to create
-- the table, and move all the data to it.
CREATE TABLE volume_types_v31 (
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
deleted BOOLEAN,
id VARCHAR(36) NOT NULL,
name VARCHAR(255),
qos_specs_id VARCHAR(36),
PRIMARY KEY (id),
CHECK (deleted IN (0, 1)),
FOREIGN KEY(qos_specs_id) REFERENCES quality_of_service_specs (id)
);
INSERT INTO volume_types_v31
SELECT created_at,
updated_at,
deleted_at,
deleted,
id,
name,
qos_specs_id
FROM volume_types;
DROP TABLE volume_types;
ALTER TABLE volume_types_v31 RENAME TO volume_types;
DROP TABLE volume_type_projects;

View File

@ -203,6 +203,7 @@ class VolumeTypes(BASE, CinderBase):
# A reference to qos_specs entity
qos_specs_id = Column(String(36),
ForeignKey('quality_of_service_specs.id'))
is_public = Column(Boolean, default=True)
volumes = relationship(Volume,
backref=backref('volume_type', uselist=False),
foreign_keys=id,
@ -211,6 +212,27 @@ class VolumeTypes(BASE, CinderBase):
'VolumeTypes.deleted == False)')
class VolumeTypeProjects(BASE, CinderBase):
"""Represent projects associated volume_types."""
__tablename__ = "volume_type_projects"
__table_args__ = (schema.UniqueConstraint(
"volume_type_id", "project_id", "deleted",
name="uniq_volume_type_projects0volume_type_id0project_id0deleted"),
)
id = Column(Integer, primary_key=True)
volume_type_id = Column(Integer, ForeignKey('volume_types.id'),
nullable=False)
project_id = Column(String(255))
volume_type = relationship(
VolumeTypes,
backref="projects",
foreign_keys=volume_type_id,
primaryjoin='and_('
'VolumeTypeProjects.volume_type_id == VolumeTypes.id,'
'VolumeTypeProjects.deleted == False)')
class VolumeTypeExtraSpecs(BASE, CinderBase):
"""Represents additional specs as key/value pairs for a volume_type."""
__tablename__ = 'volume_type_extra_specs'

View File

@ -271,6 +271,11 @@ class VolumeTypeNotFoundByName(VolumeTypeNotFound):
"could not be found.")
class VolumeTypeAccessNotFound(NotFound):
message = _("Volume type access not found for %(volume_type_id)s / "
"%(project_id)s combination.")
class VolumeTypeExtraSpecsNotFound(NotFound):
message = _("Volume Type %(volume_type_id)s has no extra specs with "
"key %(extra_specs_key)s.")
@ -376,6 +381,11 @@ class VolumeTypeExists(Duplicate):
message = _("Volume Type %(id)s already exists.")
class VolumeTypeAccessExists(Duplicate):
message = _("Volume type access for %(volume_type_id)s / "
"%(project_id)s combination already exists.")
class VolumeTypeEncryptionExists(Invalid):
message = _("Volume type encryption for type %(type_id)s already exists.")

View File

@ -57,7 +57,7 @@ def stub_volume_type_extra_specs():
return specs
def volume_type_get(context, volume_type_id):
def volume_type_get(context, id, inactive=False, expected_fields=None):
pass

View File

@ -50,11 +50,11 @@ def return_volume_types_with_volumes_destroy(context, id):
pass
def return_volume_types_create(context, name, specs):
def return_volume_types_create(context, name, specs, is_public):
pass
def return_volume_types_create_duplicate_type(context, name, specs):
def return_volume_types_create_duplicate_type(context, name, specs, is_public):
raise exception.VolumeTypeExists(id=name)

View File

@ -0,0 +1,306 @@
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import datetime
import webob
from cinder.api.contrib import volume_type_access as type_access
from cinder.api.v2 import types as types_api_v2
from cinder import context
from cinder import db
from cinder import exception
from cinder import test
from cinder.tests.api import fakes
def generate_type(type_id, is_public):
return {
'id': type_id,
'name': u'test',
'deleted': False,
'created_at': datetime.datetime(2012, 1, 1, 1, 1, 1, 1),
'updated_at': None,
'deleted_at': None,
'is_public': bool(is_public)
}
VOLUME_TYPES = {
'0': generate_type('0', True),
'1': generate_type('1', True),
'2': generate_type('2', False),
'3': generate_type('3', False)}
PROJ1_UUID = '11111111-1111-1111-1111-111111111111'
PROJ2_UUID = '22222222-2222-2222-2222-222222222222'
PROJ3_UUID = '33333333-3333-3333-3333-333333333333'
ACCESS_LIST = [{'volume_type_id': '2', 'project_id': PROJ2_UUID},
{'volume_type_id': '2', 'project_id': PROJ3_UUID},
{'volume_type_id': '3', 'project_id': PROJ3_UUID}]
def fake_volume_type_get(context, id, inactive=False, expected_fields=None):
vol = VOLUME_TYPES[id]
if expected_fields and 'projects' in expected_fields:
vol['projects'] = [a['project_id']
for a in ACCESS_LIST if a['volume_type_id'] == id]
return vol
def _has_type_access(type_id, project_id):
for access in ACCESS_LIST:
if access['volume_type_id'] == type_id and \
access['project_id'] == project_id:
return True
return False
def fake_volume_type_get_all(context, inactive=False, filters=None):
if filters is None or filters['is_public'] is None:
return VOLUME_TYPES
res = {}
for k, v in VOLUME_TYPES.iteritems():
if filters['is_public'] and _has_type_access(k, context.project_id):
res.update({k: v})
continue
if v['is_public'] == filters['is_public']:
res.update({k: v})
return res
class FakeResponse(object):
obj = {'volume_type': {'id': '0'},
'volume_types': [
{'id': '0'},
{'id': '2'}]}
def attach(self, **kwargs):
pass
class FakeRequest(object):
environ = {"cinder.context": context.get_admin_context()}
def cached_resource_by_id(self, resource_id, name=None):
return VOLUME_TYPES[resource_id]
class VolumeTypeAccessTest(test.TestCase):
def setUp(self):
super(VolumeTypeAccessTest, self).setUp()
self.type_controller_v2 = types_api_v2.VolumeTypesController()
self.type_access_controller = type_access.VolumeTypeAccessController()
self.type_action_controller = type_access.VolumeTypeActionController()
self.req = FakeRequest()
self.context = self.req.environ['cinder.context']
self.stubs.Set(db, 'volume_type_get',
fake_volume_type_get)
self.stubs.Set(db, 'volume_type_get_all',
fake_volume_type_get_all)
def assertVolumeTypeListEqual(self, expected, observed):
self.assertEqual(len(expected), len(observed))
expected = sorted(expected, key=lambda item: item['id'])
observed = sorted(observed, key=lambda item: item['id'])
for d1, d2 in zip(expected, observed):
self.assertEqual(d1['id'], d2['id'])
def test_list_type_access_public(self):
"""Querying os-volume-type-access on public type should return 404."""
req = fakes.HTTPRequest.blank('/v2/fake/types/os-volume-type-access',
use_admin_context=True)
self.assertRaises(webob.exc.HTTPNotFound,
self.type_access_controller.index,
req, '1')
def test_list_type_access_private(self):
expected = {'volume_type_access': [
{'volume_type_id': '2', 'project_id': PROJ2_UUID},
{'volume_type_id': '2', 'project_id': PROJ3_UUID}]}
result = self.type_access_controller.index(self.req, '2')
self.assertEqual(expected, result)
def test_list_with_no_context(self):
req = fakes.HTTPRequest.blank('/v2/flavors/fake/flavors')
def fake_authorize(context, target=None, action=None):
raise exception.PolicyNotAuthorized(action='index')
self.stubs.Set(type_access, 'authorize', fake_authorize)
self.assertRaises(exception.PolicyNotAuthorized,
self.type_access_controller.index,
req, 'fake')
def test_list_type_with_admin_default_proj1(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types',
use_admin_context=True)
req.environ['cinder.context'].project_id = PROJ1_UUID
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_admin_default_proj2(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}, {'id': '2'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types',
use_admin_context=True)
req.environ['cinder.context'].project_id = PROJ2_UUID
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_admin_ispublic_true(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=true',
use_admin_context=True)
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_admin_ispublic_false(self):
expected = {'volume_types': [{'id': '2'}, {'id': '3'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=false',
use_admin_context=True)
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_admin_ispublic_false_proj2(self):
expected = {'volume_types': [{'id': '2'}, {'id': '3'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=false',
use_admin_context=True)
req.environ['cinder.context'].project_id = PROJ2_UUID
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_admin_ispublic_none(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}, {'id': '2'},
{'id': '3'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=none',
use_admin_context=True)
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_no_admin_default(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types',
use_admin_context=False)
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_no_admin_ispublic_true(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=true',
use_admin_context=False)
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_no_admin_ispublic_false(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=false',
use_admin_context=False)
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_list_type_with_no_admin_ispublic_none(self):
expected = {'volume_types': [{'id': '0'}, {'id': '1'}]}
req = fakes.HTTPRequest.blank('/v2/fake/types?is_public=none',
use_admin_context=False)
result = self.type_controller_v2.index(req)
self.assertVolumeTypeListEqual(expected['volume_types'],
result['volume_types'])
def test_show(self):
resp = FakeResponse()
self.type_action_controller.show(self.req, resp, '0')
self.assertEqual({'id': '0', 'os-volume-type-access:is_public': True},
resp.obj['volume_type'])
self.type_action_controller.show(self.req, resp, '2')
self.assertEqual({'id': '0', 'os-volume-type-access:is_public': False},
resp.obj['volume_type'])
def test_detail(self):
resp = FakeResponse()
self.type_action_controller.detail(self.req, resp)
self.assertEqual(
[{'id': '0', 'os-volume-type-access:is_public': True},
{'id': '2', 'os-volume-type-access:is_public': False}],
resp.obj['volume_types'])
def test_create(self):
resp = FakeResponse()
self.type_action_controller.create(self.req, {}, resp)
self.assertEqual({'id': '0', 'os-volume-type-access:is_public': True},
resp.obj['volume_type'])
def test_add_project_access(self):
def stub_add_volume_type_access(context, type_id, project_id):
self.assertEqual('3', type_id, "type_id")
self.assertEqual(PROJ2_UUID, project_id, "project_id")
self.stubs.Set(db, 'volume_type_access_add',
stub_add_volume_type_access)
body = {'addProjectAccess': {'project': PROJ2_UUID}}
req = fakes.HTTPRequest.blank('/v2/fake/types/2/action',
use_admin_context=True)
result = self.type_action_controller._addProjectAccess(req, '3', body)
self.assertEqual(202, result.status_code)
def test_add_project_access_with_no_admin_user(self):
req = fakes.HTTPRequest.blank('/v2/fake/types/2/action',
use_admin_context=False)
body = {'addProjectAccess': {'project': PROJ2_UUID}}
self.assertRaises(exception.PolicyNotAuthorized,
self.type_action_controller._addProjectAccess,
req, '2', body)
def test_add_project_access_with_already_added_access(self):
def stub_add_volume_type_access(context, type_id, project_id):
raise exception.VolumeTypeAccessExists(volume_type_id=type_id,
project_id=project_id)
self.stubs.Set(db, 'volume_type_access_add',
stub_add_volume_type_access)
body = {'addProjectAccess': {'project': PROJ2_UUID}}
req = fakes.HTTPRequest.blank('/v2/fake/types/2/action',
use_admin_context=True)
self.assertRaises(webob.exc.HTTPConflict,
self.type_action_controller._addProjectAccess,
req, '3', body)
def test_remove_project_access_with_bad_access(self):
def stub_remove_volume_type_access(context, type_id, project_id):
raise exception.VolumeTypeAccessNotFound(volume_type_id=type_id,
project_id=project_id)
self.stubs.Set(db, 'volume_type_access_remove',
stub_remove_volume_type_access)
body = {'removeProjectAccess': {'project': PROJ2_UUID}}
req = fakes.HTTPRequest.blank('/v2/fake/types/2/action',
use_admin_context=True)
self.assertRaises(webob.exc.HTTPNotFound,
self.type_action_controller._removeProjectAccess,
req, '3', body)
def test_remove_project_access_with_no_admin_user(self):
req = fakes.HTTPRequest.blank('/v2/fake/types/2/action',
use_admin_context=False)
body = {'removeProjectAccess': {'project': PROJ2_UUID}}
self.assertRaises(exception.PolicyNotAuthorized,
self.type_action_controller._removeProjectAccess,
req, '2', body)

View File

@ -36,13 +36,13 @@ def stub_volume_type(id):
return dict(id=id, name='vol_type_%s' % str(id), extra_specs=specs)
def return_volume_types_get_all_types(context):
def return_volume_types_get_all_types(context, search_opts=None):
return dict(vol_type_1=stub_volume_type(1),
vol_type_2=stub_volume_type(2),
vol_type_3=stub_volume_type(3))
def return_empty_volume_types_get_all_types(context):
def return_empty_volume_types_get_all_types(context, search_opts=None):
return {}

View File

@ -42,7 +42,7 @@ def stub_volume_type(id):
)
def return_volume_types_get_all_types(context):
def return_volume_types_get_all_types(context, search_opts=None):
return dict(
vol_type_1=stub_volume_type(1),
vol_type_2=stub_volume_type(2),
@ -50,7 +50,7 @@ def return_volume_types_get_all_types(context):
)
def return_empty_volume_types_get_all_types(context):
def return_empty_volume_types_get_all_types(context, search_opts=None):
return {}

View File

@ -45,6 +45,9 @@
"volume_extension:volume_actions:upload_image": "",
"volume_extension:types_manage": "",
"volume_extension:types_extra_specs": "",
"volume_extension:volume_type_access": "",
"volume_extension:volume_type_access:addProjectAccess": "rule:admin_api",
"volume_extension:volume_type_access:removeProjectAccess": "rule:admin_api",
"volume_extension:volume_type_encryption": "rule:admin_api",
"volume_extension:volume_encryption_metadata": "rule:admin_or_owner",
"volume_extension:qos_specs_manage": "",

View File

@ -1298,3 +1298,53 @@ class TestMigrations(test.TestCase):
execute().scalar()
self.assertEqual(4, num_defaults)
def test_migration_032(self):
"""Test adding volume_type_projects table works correctly."""
for (key, engine) in self.engines.items():
migration_api.version_control(engine,
TestMigrations.REPOSITORY,
migration.db_initial_version())
migration_api.upgrade(engine, TestMigrations.REPOSITORY, 31)
metadata = sqlalchemy.schema.MetaData()
metadata.bind = engine
migration_api.upgrade(engine, TestMigrations.REPOSITORY, 32)
self.assertTrue(engine.dialect.has_table(engine.connect(),
"volume_type_projects"))
volume_type_projects = sqlalchemy.Table('volume_type_projects',
metadata,
autoload=True)
self.assertIsInstance(volume_type_projects.c.created_at.type,
self.time_type[engine.name])
self.assertIsInstance(volume_type_projects.c.updated_at.type,
self.time_type[engine.name])
self.assertIsInstance(volume_type_projects.c.deleted_at.type,
self.time_type[engine.name])
self.assertIsInstance(volume_type_projects.c.deleted.type,
self.bool_type[engine.name])
self.assertIsInstance(volume_type_projects.c.id.type,
sqlalchemy.types.INTEGER)
self.assertIsInstance(volume_type_projects.c.volume_type_id.type,
sqlalchemy.types.VARCHAR)
self.assertIsInstance(volume_type_projects.c.project_id.type,
sqlalchemy.types.VARCHAR)
volume_types = sqlalchemy.Table('volume_types',
metadata,
autoload=True)
self.assertIsInstance(volume_types.c.is_public.type,
self.bool_type[engine.name])
migration_api.downgrade(engine, TestMigrations.REPOSITORY, 31)
metadata = sqlalchemy.schema.MetaData()
metadata.bind = engine
self.assertFalse(engine.dialect.has_table(engine.connect(),
"volume_type_projects"))
volume_types = sqlalchemy.Table('volume_types',
metadata,
autoload=True)
self.assertNotIn('is_public', volume_types.c)

View File

@ -229,6 +229,24 @@ class VolumeTypeTestCase(test.TestCase):
encryption)
self.assertTrue(volume_types.is_encrypted(self.ctxt, volume_type_id))
def test_add_access(self):
project_id = '456'
vtype = volume_types.create(self.ctxt, 'type1')
vtype_id = vtype.get('id')
volume_types.add_volume_type_access(self.ctxt, vtype_id, project_id)
vtype_access = db.volume_type_access_get_all(self.ctxt, vtype_id)
self.assertIn(project_id, [a.project_id for a in vtype_access])
def test_remove_access(self):
project_id = '456'
vtype = volume_types.create(self.ctxt, 'type1', projects=['456'])
vtype_id = vtype.get('id')
volume_types.remove_volume_type_access(self.ctxt, vtype_id, project_id)
vtype_access = db.volume_type_access_get_all(self.ctxt, vtype_id)
self.assertNotIn(project_id, vtype_access)
def test_get_volume_type_qos_specs(self):
qos_ref = qos_specs.create(self.ctxt, 'qos-specs-1', {'k1': 'v1',
'k2': 'v2',

View File

@ -414,6 +414,14 @@ def is_valid_boolstr(val):
val == '1' or val == '0')
def is_none_string(val):
"""Check if a string represents a None value."""
if not isinstance(val, six.string_types):
return False
return val.lower() == 'none'
def monkey_patch():
"""If the CONF.monkey_patch set as True,
this function patches a decorator

View File

@ -34,13 +34,16 @@ CONF = cfg.CONF
LOG = logging.getLogger(__name__)
def create(context, name, extra_specs=None):
def create(context, name, extra_specs=None, is_public=True, projects=None):
"""Creates volume types."""
extra_specs = extra_specs or {}
projects = projects or []
try:
type_ref = db.volume_type_create(context,
dict(name=name,
extra_specs=extra_specs))
extra_specs=extra_specs,
is_public=is_public),
projects=projects)
except db_exc.DBError as e:
LOG.exception(_LE('DB error: %s') % e)
raise exception.VolumeTypeCreateFailed(name=name,
@ -64,7 +67,13 @@ def get_all_types(context, inactive=0, search_opts=None):
"""
search_opts = search_opts or {}
vol_types = db.volume_type_get_all(context, inactive)
filters = {}
if 'is_public' in search_opts:
filters['is_public'] = search_opts['is_public']
del search_opts['is_public']
vol_types = db.volume_type_get_all(context, inactive, filters=filters)
if search_opts:
LOG.debug("Searching by: %s" % search_opts)
@ -96,7 +105,7 @@ def get_all_types(context, inactive=0, search_opts=None):
return vol_types
def get_volume_type(ctxt, id):
def get_volume_type(ctxt, id, expected_fields=None):
"""Retrieves single volume type by id."""
if id is None:
msg = _("id cannot be None")
@ -105,7 +114,7 @@ def get_volume_type(ctxt, id):
if ctxt is None:
ctxt = context.get_admin_context()
return db.volume_type_get(ctxt, id)
return db.volume_type_get(ctxt, id, expected_fields=expected_fields)
def get_volume_type_by_name(context, name):
@ -149,6 +158,22 @@ def get_volume_type_extra_specs(volume_type_id, key=False):
return extra_specs
def add_volume_type_access(context, volume_type_id, project_id):
"""Add access to volume type for project_id."""
if volume_type_id is None:
msg = _("volume_type_id cannot be None")
raise exception.InvalidVolumeType(reason=msg)
return db.volume_type_access_add(context, volume_type_id, project_id)
def remove_volume_type_access(context, volume_type_id, project_id):
"""Remove access to volume type for project_id."""
if volume_type_id is None:
msg = _("volume_type_id cannot be None")
raise exception.InvalidVolumeType(reason=msg)
return db.volume_type_access_remove(context, volume_type_id, project_id)
def is_encrypted(context, volume_type_id):
if volume_type_id is None:
return False

View File

@ -21,6 +21,9 @@
"volume_extension:types_manage": "rule:admin_api",
"volume_extension:types_extra_specs": "rule:admin_api",
"volume_extension:volume_type_access": "",
"volume_extension:volume_type_access:addProjectAccess": "rule:admin_api",
"volume_extension:volume_type_access:removeProjectAccess": "rule:admin_api",
"volume_extension:volume_type_encryption": "rule:admin_api",
"volume_extension:volume_encryption_metadata": "rule:admin_or_owner",
"volume_extension:extended_snapshot_attributes": "",