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
This commit is contained in:
Dylan Zapzalka 2021-03-22 00:57:35 -05:00 committed by Artem Goncharov
parent 9e9fc98795
commit 71a8466f0f
8 changed files with 448 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
---
features:
- |
Add support for groups to the block storage service.