diff --git a/doc/source/model.rst b/doc/source/model.rst index 40541730a..db3a9d69a 100644 --- a/doc/source/model.rst +++ b/doc/source/model.rst @@ -311,3 +311,35 @@ A volume from cinder. is_encrypted=bool(), can_multiattach=bool(), properties=dict()) + + +VolumeType +------ + +A volume type from cinder. + +.. code-block:: python + + VolumeType = dict( + location=Location(), + id=str(), + name=str(), + description=str() or None, + is_public=bool(), + qos_specs_id=str() or None, + extra_specs=dict(), + properties=dict()) + + +VolumeTypeAccess +------ + +A volume type access from cinder. + +.. code-block:: python + + VolumeTypeAccess = dict( + location=Location(), + volume_type_id=str(), + project_id=str(), + properties=dict()) diff --git a/releasenotes/notes/volume-types-a07a14ae668e7dd2.yaml b/releasenotes/notes/volume-types-a07a14ae668e7dd2.yaml new file mode 100644 index 000000000..59fea21bb --- /dev/null +++ b/releasenotes/notes/volume-types-a07a14ae668e7dd2.yaml @@ -0,0 +1,4 @@ +--- +features: + - Add support for listing volume types. + - Add support for managing volume type access. diff --git a/shade/_normalize.py b/shade/_normalize.py index 003b368f2..dd56d9d68 100644 --- a/shade/_normalize.py +++ b/shade/_normalize.py @@ -614,6 +614,56 @@ class Normalizer(object): return ret + def _normalize_volume_type_access(self, volume_type_access): + + volume_type_access = volume_type_access.copy() + + volume_type_id = volume_type_access.pop('volume_type_id') + project_id = volume_type_access.pop('project_id') + ret = munch.Munch( + location=self.current_location, + project_id=project_id, + volume_type_id=volume_type_id, + properties=volume_type_access.copy(), + ) + return ret + + def _normalize_volume_type_accesses(self, volume_type_accesses): + ret = [] + for volume_type_access in volume_type_accesses: + ret.append(self._normalize_volume_type_access(volume_type_access)) + return ret + + def _normalize_volume_type(self, volume_type): + + volume_type = volume_type.copy() + + volume_id = volume_type.pop('id') + description = volume_type.pop('description', None) + name = volume_type.pop('name', None) + old_is_public = volume_type.pop('os-volume-type-access:is_public', + False) + is_public = volume_type.pop('is_public', old_is_public) + qos_specs_id = volume_type.pop('qos_specs_id', None) + extra_specs = volume_type.pop('extra_specs', {}) + ret = munch.Munch( + location=self.current_location, + is_public=is_public, + id=volume_id, + name=name, + description=description, + qos_specs_id=qos_specs_id, + extra_specs=extra_specs, + properties=volume_type.copy(), + ) + return ret + + def _normalize_volume_types(self, volume_types): + ret = [] + for volume in volume_types: + ret.append(self._normalize_volume_type(volume)) + return ret + def _normalize_volumes(self, volumes): """Normalize the structure of volumes diff --git a/shade/_tasks.py b/shade/_tasks.py index daff9a681..b46eab4d8 100644 --- a/shade/_tasks.py +++ b/shade/_tasks.py @@ -301,6 +301,28 @@ class ImageSnapshotCreate(task_manager.Task): return client.nova_client.servers.create_image(**self.args) +class VolumeTypeList(task_manager.Task): + def main(self, client): + return client.cinder_client.volume_types.list() + + +class VolumeTypeAccessList(task_manager.Task): + def main(self, client): + return client.cinder_client.volume_type_access.list(**self.args) + + +class VolumeTypeAccessAdd(task_manager.Task): + def main(self, client): + return client.cinder_client.volume_type_access.add_project_access( + **self.args) + + +class VolumeTypeAccessRemove(task_manager.Task): + def main(self, client): + return client.cinder_client.volume_type_access.remove_project_access( + **self.args) + + class VolumeCreate(task_manager.Task): def main(self, client): return client.cinder_client.volumes.create(**self.args) diff --git a/shade/openstackcloud.py b/shade/openstackcloud.py index 07246f4dd..e695c33d7 100644 --- a/shade/openstackcloud.py +++ b/shade/openstackcloud.py @@ -1471,6 +1471,11 @@ class OpenStackCloud(_normalize.Normalizer): return _utils._filter_list( volume_backups, name_or_id, filters) + def search_volume_types( + self, name_or_id=None, filters=None, get_extra=True): + volume_types = self.list_volume_types(get_extra=get_extra) + return _utils._filter_list(volume_types, name_or_id, filters) + def search_flavors(self, name_or_id=None, filters=None, get_extra=True): flavors = self.list_flavors(get_extra=get_extra) return _utils._filter_list(flavors, name_or_id, filters) @@ -1637,6 +1642,17 @@ class OpenStackCloud(_normalize.Normalizer): return self._normalize_volumes( self.manager.submit_task(_tasks.VolumeList())) + @_utils.cache_on_arguments() + def list_volume_types(self, get_extra=True): + """List all available volume types. + + :returns: A list of volume ``munch.Munch``. + + """ + with _utils.shade_exceptions("Error fetching volume_type list"): + return self._normalize_volume_types( + self.manager.submit_task(_tasks.VolumeTypeList())) + @_utils.cache_on_arguments() def list_flavors(self, get_extra=True): """List all available flavors. @@ -2379,6 +2395,31 @@ class OpenStackCloud(_normalize.Normalizer): """ return _utils._get_entity(self.search_volumes, name_or_id, filters) + def get_volume_type(self, name_or_id, filters=None): + """Get a volume type by name or ID. + + :param name_or_id: Name or ID of the volume. + :param filters: + A dictionary of meta data to use for further filtering. Elements + of this dictionary may, themselves, be dictionaries. Example:: + + { + 'last_name': 'Smith', + 'other': { + 'gender': 'Female' + } + } + OR + A string containing a jmespath expression for further filtering. + Example:: "[?last_name==`Smith`] | [?other.gender]==`Female`]" + + :returns: A volume ``munch.Munch`` or None if no matching volume is + found. + + """ + return _utils._get_entity( + self.search_volume_types, name_or_id, filters) + def get_flavor(self, name_or_id, filters=None, get_extra=True): """Get a flavor by name or ID. diff --git a/shade/operatorcloud.py b/shade/operatorcloud.py index 8b26eac7a..0c59e80d7 100644 --- a/shade/operatorcloud.py +++ b/shade/operatorcloud.py @@ -1977,6 +1977,70 @@ class OperatorCloud(openstackcloud.OpenStackCloud): return self.manager.submit_task(_tasks.AggregateRemoveHost( aggregate=aggregate['id'], host=host_name)) + def get_volume_type_access(self, name_or_id): + """Return a list of volume_type_access. + + :param name_or_id: Name or ID of the volume type. + + :raises: OpenStackCloudException on operation error. + """ + volume_type = self.get_volume_type(name_or_id) + if not volume_type: + raise OpenStackCloudException( + "VolumeType not found: %s" % name_or_id) + + with _utils.shade_exceptions( + "Unable to get volume type access {name}".format( + name=name_or_id)): + return self._normalize_volume_type_accesses( + self.manager.submit_task( + _tasks.VolumeTypeAccessList(volume_type=volume_type)) + ) + + def add_volume_type_access(self, name_or_id, project_id): + """Grant access on a volume_type to a project. + + :param name_or_id: ID or name of a volume_type + :param project_id: A project id + + NOTE: the call works even if the project does not exist. + + :raises: OpenStackCloudException on operation error. + """ + volume_type = self.get_volume_type(name_or_id) + if not volume_type: + raise OpenStackCloudException( + "VolumeType not found: %s" % name_or_id) + with _utils.shade_exceptions( + "Unable to authorize {project} " + "to use volume type {name}".format( + name=name_or_id, project=project_id + )): + self.manager.submit_task( + _tasks.VolumeTypeAccessAdd( + volume_type=volume_type, project=project_id)) + + def remove_volume_type_access(self, name_or_id, project_id): + """Revoke access on a volume_type to a project. + + :param name_or_id: ID or name of a volume_type + :param project_id: A project id + + :raises: OpenStackCloudException on operation error. + """ + volume_type = self.get_volume_type(name_or_id) + if not volume_type: + raise OpenStackCloudException( + "VolumeType not found: %s" % name_or_id) + with _utils.shade_exceptions( + "Unable to revoke {project} " + "to use volume type {name}".format( + name=name_or_id, project=project_id + )): + self.manager.submit_task( + _tasks.VolumeTypeAccessRemove( + volume_type=volume_type, project=project_id)) + def set_compute_quotas(self, name_or_id, **kwargs): """ Set a quota in a project diff --git a/shade/tests/functional/test_volume_type.py b/shade/tests/functional/test_volume_type.py new file mode 100644 index 000000000..bb3226fc0 --- /dev/null +++ b/shade/tests/functional/test_volume_type.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- + +# 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. + +""" +test_volume +---------------------------------- + +Functional tests for `shade` block storage methods. +""" +import testtools +from shade.exc import OpenStackCloudException +from shade.tests.functional import base + + +class TestVolumeType(base.BaseFunctionalTestCase): + + def _assert_project(self, volume_name_or_id, project_id, allowed=True): + acls = self.operator_cloud.get_volume_type_access(volume_name_or_id) + allowed_projects = [x.get('project_id') for x in acls] + self.assertEqual(allowed, project_id in allowed_projects) + + def setUp(self): + super(TestVolumeType, self).setUp() + if not self.demo_cloud.has_service('volume'): + self.skipTest('volume service not supported by cloud') + self.operator_cloud.cinder_client.volume_types.create( + 'test-volume-type', is_public=False) + + def tearDown(self): + ret = self.operator_cloud.get_volume_type('test-volume-type') + if ret.get('id'): + self.operator_cloud.cinder_client.volume_types.delete(ret.id) + super(TestVolumeType, self).tearDown() + + def test_list_volume_types(self): + volume_types = self.operator_cloud.list_volume_types() + self.assertTrue(volume_types) + self.assertTrue(any( + x for x in volume_types if x.name == 'test-volume-type')) + + def test_add_remove_volume_type_access(self): + volume_type = self.operator_cloud.get_volume_type('test-volume-type') + self.assertEqual('test-volume-type', volume_type.name) + + self.operator_cloud.add_volume_type_access( + 'test-volume-type', + self.operator_cloud.current_project_id) + self._assert_project( + 'test-volume-type', self.operator_cloud.current_project_id, + allowed=True) + + self.operator_cloud.remove_volume_type_access( + 'test-volume-type', + self.operator_cloud.current_project_id) + self._assert_project( + 'test-volume-type', self.operator_cloud.current_project_id, + allowed=False) + + def test_add_volume_type_access_missing_project(self): + # Project id is not valitaded and it may not exist. + self.operator_cloud.add_volume_type_access( + 'test-volume-type', + '00000000000000000000000000000000') + + self.operator_cloud.remove_volume_type_access( + 'test-volume-type', + '00000000000000000000000000000000') + + def test_add_volume_type_access_missing_volume(self): + with testtools.ExpectedException( + OpenStackCloudException, + "VolumeType not found.*" + ): + self.operator_cloud.add_volume_type_access( + 'MISSING_VOLUME_TYPE', + self.operator_cloud.current_project_id) + + def test_remove_volume_type_access_missing_volume(self): + with testtools.ExpectedException( + OpenStackCloudException, + "VolumeType not found.*" + ): + self.operator_cloud.remove_volume_type_access( + 'MISSING_VOLUME_TYPE', + self.operator_cloud.current_project_id) + + def test_add_volume_type_access_bad_project(self): + with testtools.ExpectedException( + OpenStackCloudException, + "Unable to authorize.*" + ): + self.operator_cloud.add_volume_type_access( + 'test-volume-type', + 'BAD_PROJECT_ID') + + def test_remove_volume_type_access_missing_project(self): + with testtools.ExpectedException( + OpenStackCloudException, + "Unable to revoke.*" + ): + self.operator_cloud.remove_volume_type_access( + 'test-volume-type', + '00000000000000000000000000000000') diff --git a/shade/tests/unit/test_volume_access.py b/shade/tests/unit/test_volume_access.py new file mode 100644 index 000000000..29a8e2067 --- /dev/null +++ b/shade/tests/unit/test_volume_access.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- + +# 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 mock +import testtools + +import shade +from shade.tests.unit import base + + +class TestVolumeAccess(base.TestCase): + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + def test_list_volume_types(self, mock_cinder): + volume_type = dict( + id='voltype01', description='volume type description', + name='name', is_public=False) + mock_cinder.volume_types.list.return_value = [volume_type] + + self.assertTrue(self.cloud.list_volume_types()) + + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + def test_get_volume_type(self, mock_cinder): + volume_type = dict( + id='voltype01', description='volume type description', name='name', + is_public=False) + mock_cinder.volume_types.list.return_value = [volume_type] + + volume_type_got = self.cloud.get_volume_type('name') + self.assertEqual(volume_type_got.id, volume_type['id']) + + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + def test_get_volume_type_access(self, mock_cinder): + volume_type = dict( + id='voltype01', description='volume type description', name='name', + is_public=False) + volume_type_access = [ + dict(volume_type_id='voltype01', name='name', project_id='prj01'), + dict(volume_type_id='voltype01', name='name', project_id='prj02') + ] + mock_cinder.volume_types.list.return_value = [volume_type] + mock_cinder.volume_type_access.list.return_value = volume_type_access + + self.assertEqual( + len(self.op_cloud.get_volume_type_access('name')), 2) + + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + def test_remove_volume_type_access(self, mock_cinder): + volume_type = dict( + id='voltype01', description='volume type description', name='name', + is_public=False) + project_001 = dict(volume_type_id='voltype01', name='name', + project_id='prj01') + project_002 = dict(volume_type_id='voltype01', name='name', + project_id='prj02') + volume_type_access = [project_001, project_002] + mock_cinder.volume_types.list.return_value = [volume_type] + mock_cinder.volume_type_access.list.return_value = volume_type_access + + def _fake_remove(*args, **kwargs): + volume_type_access.pop() + + mock_cinder.volume_type_access.remove_project_access.side_effect = \ + _fake_remove + + self.assertEqual( + len(self.op_cloud.get_volume_type_access( + volume_type['name'])), 2) + self.op_cloud.remove_volume_type_access( + volume_type['name'], project_001['project_id']) + + self.assertEqual( + len(self.op_cloud.get_volume_type_access('name')), 1) + + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + def test_add_volume_type_access(self, mock_cinder): + volume_type = dict( + id='voltype01', description='volume type description', name='name', + is_public=False) + project_001 = dict(volume_type_id='voltype01', name='name', + project_id='prj01') + project_002 = dict(volume_type_id='voltype01', name='name', + project_id='prj02') + volume_type_access = [project_001] + mock_cinder.volume_types.list.return_value = [volume_type] + mock_cinder.volume_type_access.list.return_value = volume_type_access + mock_cinder.volume_type_access.add_project_access.return_value = None + + def _fake_add(*args, **kwargs): + volume_type_access.append(project_002) + + mock_cinder.volume_type_access.add_project_access.side_effect = \ + _fake_add + + self.op_cloud.add_volume_type_access( + volume_type['name'], project_002['project_id']) + self.assertEqual( + len(self.op_cloud.get_volume_type_access('name')), 2) + + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + def test_add_volume_type_access_missing(self, mock_cinder): + volume_type = dict( + id='voltype01', description='volume type description', name='name', + is_public=False) + project_001 = dict(volume_type_id='voltype01', name='name', + project_id='prj01') + mock_cinder.volume_types.list.return_value = [volume_type] + with testtools.ExpectedException(shade.OpenStackCloudException, + "VolumeType not found: MISSING"): + self.op_cloud.add_volume_type_access( + "MISSING", project_001['project_id'])