From 71a8466f0ff1042367967336665a9d276679bb66 Mon Sep 17 00:00:00 2001 From: Dylan Zapzalka Date: Mon, 22 Mar 2021 00:57:35 -0500 Subject: [PATCH] block storage: Add support for the Group resource Introduce the Group resource, fill in its resources, and implement API calls to support the Cinder v3 API. Change-Id: Ied48b46eb76dfe6cbafa3f08ac8f5bfe78af4058 --- doc/source/user/proxies/block_storage_v3.rst | 8 ++ openstack/block_storage/v3/_proxy.py | 117 +++++++++++++++ openstack/block_storage/v3/group.py | 89 ++++++++++++ openstack/block_storage/v3/volume.py | 2 + .../functional/block_storage/v3/test_group.py | 60 +++++++- .../tests/unit/block_storage/v3/test_group.py | 135 ++++++++++++++++++ .../tests/unit/block_storage/v3/test_proxy.py | 38 +++++ ...block-storage-groups-bf5f1af714c9e505.yaml | 4 + 8 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 openstack/block_storage/v3/group.py create mode 100644 openstack/tests/unit/block_storage/v3/test_group.py create mode 100644 releasenotes/notes/add-block-storage-groups-bf5f1af714c9e505.yaml diff --git a/doc/source/user/proxies/block_storage_v3.rst b/doc/source/user/proxies/block_storage_v3.rst index 73ff27cfd..5fc796549 100644 --- a/doc/source/user/proxies/block_storage_v3.rst +++ b/doc/source/user/proxies/block_storage_v3.rst @@ -63,6 +63,14 @@ Capabilities Operations :noindex: :members: get_capabilities +Group Operations +^^^^^^^^^^^^^^^^ + +.. autoclass:: openstack.block_storage.v3._proxy.Proxy + :noindex: + :members: create_group, create_group_from_source, delete_group, update_group, + get_group, find_group, groups, reset_group_state + Group Type Operations ^^^^^^^^^^^^^^^^^^^^^ diff --git a/openstack/block_storage/v3/_proxy.py b/openstack/block_storage/v3/_proxy.py index 5570f8188..bbdb1dbb4 100644 --- a/openstack/block_storage/v3/_proxy.py +++ b/openstack/block_storage/v3/_proxy.py @@ -15,6 +15,7 @@ from openstack.block_storage.v3 import availability_zone from openstack.block_storage.v3 import backup as _backup from openstack.block_storage.v3 import capabilities as _capabilities from openstack.block_storage.v3 import extension as _extension +from openstack.block_storage.v3 import group as _group from openstack.block_storage.v3 import group_type as _group_type from openstack.block_storage.v3 import limits as _limits from openstack.block_storage.v3 import quota_set as _quota_set @@ -977,6 +978,121 @@ class Proxy(_base_proxy.BaseBlockStorageProxy): """ return self._get(_capabilities.Capabilities, host) + # ====== GROUPS ====== + def get_group(self, group_id, **attrs): + """Get a group + + :param group_id: The ID of the group to get. + :param dict attrs: Optional query parameters to be sent to limit the + resources being returned. + + :returns: A Group instance. + :rtype: :class:`~openstack.block_storage.v3.group` + """ + return self._get(_group.Group, group_id, **attrs) + + def find_group(self, name_or_id, ignore_missing=True, **attrs): + """Find a single group + + :param name_or_id: The name or ID of a group. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised + when the group snapshot does not exist. + + :returns: One :class:`~openstack.block_storage.v3.group.Group` + :raises: :class:`~openstack.exceptions.ResourceNotFound` + when no resource can be found. + """ + return self._find( + _group.Group, name_or_id, ignore_missing=ignore_missing) + + def groups(self, details=True, **query): + """Retrieve a generator of groups + + :param bool details: When set to ``False``, no additional details will + be returned. The default, ``True``, will cause additional details + to be returned. + :param dict query: Optional query parameters to be sent to limit the + resources being returned: + + * all_tenants: Shows details for all project. + * sort: Comma-separated list of sort keys and optional sort + directions. + * limit: Returns a number of items up to the limit value. + * offset: Used in conjunction with limit to return a slice of + items. Specifies where to start in the list. + * marker: The ID of the last-seen item. + * list_volume: Show volume ids in this group. + * detailed: If True, will list groups with details. + * search_opts: Search options. + + :returns: A generator of group objects. + """ + base_path = '/groups/detail' if details else '/groups' + return self._list(_group.Group, base_path=base_path, **query) + + def create_group(self, **attrs): + """Create a new group from attributes + + :param dict attrs: Keyword arguments which will be used to create + a :class:`~openstack.block_storage.v3.group.Group` comprised of + the properties on the Group class. + + :returns: The results of group creation. + :rtype: :class:`~openstack.block_storage.v3.group.Group`. + """ + return self._create(_group.Group, **attrs) + + def create_group_from_source(self, **attrs): + """Creates a new group from source + + :param dict attrs: Keyword arguments which will be used to create + a :class:`~openstack.block_storage.v3.group.Group` comprised of + the properties on the Group class. + + :returns: The results of group creation. + :rtype: :class:`~openstack.block_storage.v3.group.Group`. + """ + return _group.Group.create_from_source(self, **attrs) + + def reset_group_state(self, group, status): + """Reset group status + + :param group: The :class:`~openstack.block_storage.v3.group.Group` + to set the state. + :param status: The status for a group. + + :returns: ``None`` + """ + res = self._get_resource(_group.Group, group) + return res.reset_status(self, status) + + def delete_group(self, group, delete_volumes=False): + """Delete a group + + :param group: The :class:`~openstack.block_storage.v3.group.Group` to + delete. + :param bool delete_volumes: When set to ``True``, volumes in group + will be deleted. + + :returns: ``None``. + """ + res = self._get_resource(_group.Group, group) + res.delete(self, delete_volumes=delete_volumes) + + def update_group(self, group, **attrs): + """Update a group + + :param group: The value can be the ID of a group or a + :class:`~openstack.block_storage.v3.group.Group` instance. + :param dict attrs: The attributes to update on the group. + + :returns: The updated group + :rtype: :class:`~openstack.volume.v3.group.Group` + """ + return self._update(_group.Group, group, **attrs) + + # ====== AVAILABILITY ZONES ====== def availability_zones(self): """Return a generator of availability zones @@ -987,6 +1103,7 @@ class Proxy(_base_proxy.BaseBlockStorageProxy): return self._list(availability_zone.AvailabilityZone) + # ====== GROUP TYPE ====== def get_group_type(self, group_type): """Get a specific group type diff --git a/openstack/block_storage/v3/group.py b/openstack/block_storage/v3/group.py new file mode 100644 index 000000000..b2f54e21c --- /dev/null +++ b/openstack/block_storage/v3/group.py @@ -0,0 +1,89 @@ +# 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 openstack import exceptions +from openstack import resource +from openstack import utils + + +class Group(resource.Resource): + resource_key = "group" + resources_key = "groups" + base_path = "/groups" + + # capabilities + allow_fetch = True + allow_create = True + allow_delete = True + allow_commit = True + allow_list = True + + availability_zone = resource.Body("availability_zone") + created_at = resource.Body("created_at") + description = resource.Body("description") + group_snapshot_id = resource.Body("group_snapshot_id") + group_type = resource.Body("group_type") + project_id = resource.Body("project_id") + replication_status = resource.Body("replication_status") + source_group_id = resource.Body("source_group_id") + status = resource.Body("status") + volumes = resource.Body("volumes", type=list) + volume_types = resource.Body("volume_types", type=list) + + _max_microversion = "3.38" + + def _action(self, session, body): + """Preform group actions given the message body.""" + session = self._get_session(session) + microversion = self._get_microversion_for(session, 'create') + url = utils.urljoin(self.base_path, self.id, 'action') + response = session.post(url, json=body, microversion=microversion) + exceptions.raise_from_response(response) + return response + + def delete(self, session, *, delete_volumes=False): + """Delete a group.""" + body = {'delete': {'delete-volumes': delete_volumes}} + self._action(session, body) + + def reset(self, session, status): + """Resets the status for a group.""" + body = {'reset_status': {'status': status}} + self._action(session, body) + + @classmethod + def create_from_source( + cls, + session, + group_snapshot_id, + source_group_id, + name=None, + description=None, + ): + """Creates a new group from source.""" + session = cls._get_session(session) + microversion = cls._get_microversion_for(cls, session, 'create') + url = utils.urljoin(cls.base_path, 'action') + body = { + 'create-from-src': { + 'name': name, + 'description': description, + 'group_snapshot_id': group_snapshot_id, + 'source_group_id': source_group_id, + } + } + response = session.post(url, json=body, microversion=microversion) + exceptions.raise_from_response(response) + + group = Group() + group._translate_response(response=response) + return group diff --git a/openstack/block_storage/v3/volume.py b/openstack/block_storage/v3/volume.py index 034198b64..e735fa066 100644 --- a/openstack/block_storage/v3/volume.py +++ b/openstack/block_storage/v3/volume.py @@ -49,6 +49,8 @@ class Volume(resource.Resource, metadata.MetadataMixin): #: Extended replication status on this volume. extended_replication_status = resource.Body( "os-volume-replication:extended_status") + #: The ID of the group that the volume belongs to. + group_id = resource.Body("group_id") #: The volume's current back-end. host = resource.Body("os-vol-host-attr:host") #: The ID of the image from which you want to create the volume. diff --git a/openstack/tests/functional/block_storage/v3/test_group.py b/openstack/tests/functional/block_storage/v3/test_group.py index 33e37ffba..a3929d610 100644 --- a/openstack/tests/functional/block_storage/v3/test_group.py +++ b/openstack/tests/functional/block_storage/v3/test_group.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +from openstack.block_storage.v3 import group as _group from openstack.block_storage.v3 import group_type as _group_type from openstack.tests.functional.block_storage.v3 import base @@ -22,20 +23,41 @@ class TestGroup(base.BaseBlockStorageTest): if not self.user_cloud.has_service('block-storage'): self.skipTest('block-storage service not supported by cloud') + # there will always be at least one volume type, i.e. the default one + volume_types = list(self.conn.block_storage.types()) + self.volume_type = volume_types[0] + group_type_name = self.getUniqueString() self.group_type = self.conn.block_storage.create_group_type( name=group_type_name, ) - self.addCleanup( - self.conn.block_storage.delete_group_type, - self.group_type, - ) self.assertIsInstance(self.group_type, _group_type.GroupType) self.assertEqual(group_type_name, self.group_type.name) + group_name = self.getUniqueString() + self.group = self.conn.block_storage.create_group( + name=group_name, + group_type=self.group_type.id, + volume_types=[self.volume_type.id], + ) + self.assertIsInstance(self.group, _group.Group) + self.assertEqual(group_name, self.group.name) + + def tearDown(self): + # we do this in tearDown rather than via 'addCleanup' since we need to + # wait for the deletion of the group before moving onto the deletion of + # the group type + self.conn.block_storage.delete_group(self.group, delete_volumes=True) + self.conn.block_storage.wait_for_delete(self.group) + + self.conn.block_storage.delete_group_type(self.group_type) + self.conn.block_storage.wait_for_delete(self.group_type) + + super().tearDown() + def test_group_type(self): # get - group_type = self.conn.block_storage.get_group_type(self.group_type) + group_type = self.conn.block_storage.get_group_type(self.group_type.id) self.assertEqual(self.group_type.name, group_type.name) # find @@ -101,3 +123,31 @@ class TestGroup(base.BaseBlockStorageTest): ) group_type = self.conn.block_storage.get_group_type(self.group_type.id) self.assertEqual({'acme': 'buzz'}, group_type.group_specs) + + def test_group(self): + # get + group = self.conn.block_storage.get_group(self.group.id) + self.assertEqual(self.group.name, group.name) + + # find + group = self.conn.block_storage.find_group(self.group.name) + self.assertEqual(self.group.id, group.id) + + # list + groups = self.conn.block_storage.groups() + # other tests may have created groups and there can be defaults so we + # don't assert that this is the *only* group present + self.assertIn(self.group.id, {g.id for g in groups}) + + # update + group_name = self.getUniqueString() + group_description = self.getUniqueString() + group = self.conn.block_storage.update_group( + self.group, + name=group_name, + description=group_description, + ) + self.assertIsInstance(group, _group.Group) + group = self.conn.block_storage.get_group(self.group.id) + self.assertEqual(group_name, group.name) + self.assertEqual(group_description, group.description) diff --git a/openstack/tests/unit/block_storage/v3/test_group.py b/openstack/tests/unit/block_storage/v3/test_group.py new file mode 100644 index 000000000..f8cf11a4d --- /dev/null +++ b/openstack/tests/unit/block_storage/v3/test_group.py @@ -0,0 +1,135 @@ +# 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 copy +from unittest import mock + +from keystoneauth1 import adapter + +from openstack.block_storage.v3 import group +from openstack.tests.unit import base + +GROUP_ID = "6f519a48-3183-46cf-a32f-41815f813986" + +GROUP = { + "id": GROUP_ID, + "status": "available", + "availability_zone": "az1", + "created_at": "2015-09-16T09:28:52.000000", + "name": "first_group", + "description": "my first group", + "group_type": "29514915-5208-46ab-9ece-1cc4688ad0c1", + "volume_types": ["c4daaf47-c530-4901-b28e-f5f0a359c4e6"], + "volumes": ["a2cdf1ad-5497-4e57-bd7d-f573768f3d03"], + "group_snapshot_id": None, + "source_group_id": None, + "project_id": "7ccf4863071f44aeb8f141f65780c51b" +} + + +class TestGroup(base.TestCase): + + def test_basic(self): + resource = group.Group() + self.assertEqual("group", resource.resource_key) + self.assertEqual("groups", resource.resources_key) + self.assertEqual("/groups", resource.base_path) + self.assertTrue(resource.allow_create) + self.assertTrue(resource.allow_fetch) + self.assertTrue(resource.allow_delete) + self.assertTrue(resource.allow_commit) + self.assertTrue(resource.allow_list) + + def test_make_resource(self): + resource = group.Group(**GROUP) + self.assertEqual(GROUP["id"], resource.id) + self.assertEqual(GROUP["status"], resource.status) + self.assertEqual( + GROUP["availability_zone"], resource.availability_zone) + self.assertEqual(GROUP["created_at"], resource.created_at) + self.assertEqual(GROUP["name"], resource.name) + self.assertEqual(GROUP["description"], resource.description) + self.assertEqual(GROUP["group_type"], resource.group_type) + self.assertEqual(GROUP["volume_types"], resource.volume_types) + self.assertEqual(GROUP["volumes"], resource.volumes) + self.assertEqual( + GROUP["group_snapshot_id"], resource.group_snapshot_id) + self.assertEqual(GROUP["source_group_id"], resource.source_group_id) + self.assertEqual(GROUP["project_id"], resource.project_id) + + +class TestGroupAction(base.TestCase): + + def setUp(self): + super().setUp() + self.resp = mock.Mock() + self.resp.body = None + self.resp.json = mock.Mock(return_value=self.resp.body) + self.resp.headers = {} + self.resp.status_code = 202 + + self.sess = mock.Mock(spec=adapter.Adapter) + self.sess.get = mock.Mock() + self.sess.post = mock.Mock(return_value=self.resp) + self.sess.default_microversion = '3.38' + + def test_delete(self): + sot = group.Group(**GROUP) + + self.assertIsNone(sot.delete(self.sess)) + + url = 'groups/%s/action' % GROUP_ID + body = {'delete': {'delete-volumes': False}} + self.sess.post.assert_called_with( + url, json=body, microversion=sot._max_microversion) + + def test_reset(self): + sot = group.Group(**GROUP) + + self.assertIsNone(sot.reset(self.sess, 'new_status')) + + url = 'groups/%s/action' % GROUP_ID + body = {'reset_status': {'status': 'new_status'}} + self.sess.post.assert_called_with( + url, json=body, microversion=sot._max_microversion, + ) + + def test_create_from_source(self): + resp = mock.Mock() + resp.body = {'group': copy.deepcopy(GROUP)} + resp.json = mock.Mock(return_value=resp.body) + resp.headers = {} + resp.status_code = 202 + + self.sess.post = mock.Mock(return_value=resp) + + sot = group.Group.create_from_source( + self.sess, + group_snapshot_id='9a591346-e595-4bc1-94e7-08f264406b63', + source_group_id='6c5259f6-42ed-4e41-8ffe-e1c667ae9dff', + name='group_from_source', + description='a group from source', + ) + self.assertIsNotNone(sot) + + url = 'groups/action' + body = { + 'create-from-src': { + 'name': 'group_from_source', + 'description': 'a group from source', + 'group_snapshot_id': '9a591346-e595-4bc1-94e7-08f264406b63', + 'source_group_id': '6c5259f6-42ed-4e41-8ffe-e1c667ae9dff', + }, + } + self.sess.post.assert_called_with( + url, json=body, microversion=sot._max_microversion, + ) diff --git a/openstack/tests/unit/block_storage/v3/test_proxy.py b/openstack/tests/unit/block_storage/v3/test_proxy.py index 790f6cc34..2ff15b8fc 100644 --- a/openstack/tests/unit/block_storage/v3/test_proxy.py +++ b/openstack/tests/unit/block_storage/v3/test_proxy.py @@ -16,6 +16,7 @@ from openstack.block_storage.v3 import _proxy from openstack.block_storage.v3 import backup from openstack.block_storage.v3 import capabilities from openstack.block_storage.v3 import extension +from openstack.block_storage.v3 import group from openstack.block_storage.v3 import group_type from openstack.block_storage.v3 import limits from openstack.block_storage.v3 import quota_set @@ -143,6 +144,43 @@ class TestResourceFilter(TestVolumeProxy): ) +class TestGroup(TestVolumeProxy): + def test_group_get(self): + self.verify_get(self.proxy.get_group, group.Group) + + def test_group_find(self): + self.verify_find(self.proxy.find_group, group.Group) + + def test_groups(self): + self.verify_list(self.proxy.groups, group.Group) + + def test_group_create(self): + self.verify_create(self.proxy.create_group, group.Group) + + def test_group_create_from_source(self): + self._verify( + "openstack.block_storage.v3.group.Group.create_from_source", + self.proxy.create_group_from_source, + method_args=[], + expected_args=[self.proxy], + ) + + def test_group_delete(self): + self._verify( + "openstack.block_storage.v3.group.Group.delete", + self.proxy.delete_group, + method_args=['delete_volumes'], + expected_args=[self.proxy], + expected_kwargs={'delete_volumes': False}, + ) + + def test_group_update(self): + self.verify_update(self.proxy.update_group, group.Group) + + def reset_group_state(self): + self._verify(self.proxy.reset_group_state, group.Group) + + class TestGroupType(TestVolumeProxy): def test_group_type_get(self): self.verify_get(self.proxy.get_group_type, group_type.GroupType) diff --git a/releasenotes/notes/add-block-storage-groups-bf5f1af714c9e505.yaml b/releasenotes/notes/add-block-storage-groups-bf5f1af714c9e505.yaml new file mode 100644 index 000000000..2f24f1812 --- /dev/null +++ b/releasenotes/notes/add-block-storage-groups-bf5f1af714c9e505.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add support for groups to the block storage service.