Add generic volume groups

This patch adds support to generic volume groups.

Server patch is here: https://review.openstack.org/#/c/322459/

Current microversion is 3.13. The following CLI's are supported:
cinder --os-volume-api-version 3.13 group-create --name my_group
    <group type uuid> <volume type uuid>
cinder --os-volume-api-version 3.13 group-list
cinder --os-volume-api-version 3.13 create --group-id <group uuid>
    --volume-type <volume type uuid> <size>
cinder --os-volume-api-version 3.13 group-update <group uuid>
    --name new_name  description new_description
    --add-volumes <uuid of volume to add>
    --remove-volumes <uuid of volume to remove>
cinder --os-volume-api-version 3.13 group-show <group uuid>
cinder --os-volume-api-version 3.13 group-delete
    --delete-volumes <group uuid>

Depends-on: I35157439071786872bc9976741c4ef75698f7cb7
Change-Id: Icff2d7385bde0a7c023c2ca38fffcd4bc5460af9
Partial-Implements: blueprint generic-volume-group
This commit is contained in:
xing-yang 2016-05-16 06:22:29 -04:00
parent 1610c3d95c
commit 6c5a764c77
9 changed files with 588 additions and 5 deletions

@ -32,7 +32,7 @@ if not LOG.handlers:
# key is a deprecated version and value is an alternative version.
DEPRECATED_VERSIONS = {"1": "2"}
MAX_VERSION = "3.11"
MAX_VERSION = "3.13"
_SUBSTITUTIONS = {}

@ -19,6 +19,24 @@ from cinderclient.v3 import client
from cinderclient.tests.unit.v2 import fakes as fake_v2
def _stub_group(detailed=True, **kwargs):
group = {
"name": "test-1",
"id": "1234",
}
if detailed:
details = {
"created_at": "2012-08-28T16:30:31.000000",
"description": "test-1-desc",
"availability_zone": "zone1",
"status": "available",
"group_type": "my_group_type",
}
group.update(details)
group.update(kwargs)
return group
class FakeClient(fakes.FakeClient, client.Client):
def __init__(self, api_version=None, *args, **kwargs):
@ -248,3 +266,39 @@ class FakeHTTPClient(fake_v2.FakeHTTPClient):
def put_group_types_1(self, **kw):
return self.get_group_types_1()
#
# Groups
#
def get_groups_detail(self, **kw):
return (200, {}, {"groups": [
_stub_group(id='1234'),
_stub_group(id='4567')]})
def get_groups(self, **kw):
return (200, {}, {"groups": [
_stub_group(detailed=False, id='1234'),
_stub_group(detailed=False, id='4567')]})
def get_groups_1234(self, **kw):
return (200, {}, {'group':
_stub_group(id='1234')})
def post_groups(self, **kw):
group = _stub_group(id='1234', group_type='my_group_type',
volume_types=['type1', 'type2'])
return (202, {}, {'group': group})
def put_groups_1234(self, **kw):
return (200, {}, {'group': {}})
def post_groups_1234_action(self, body, **kw):
resp = 202
assert len(list(body)) == 1
action = list(body)[0]
if action == 'delete':
assert 'delete-volumes' in body[action]
else:
raise AssertionError("Unexpected action: %s" % action)
return (resp, {}, {})

@ -0,0 +1,115 @@
# Copyright (C) 2016 EMC Corporation.
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import ddt
from cinderclient.tests.unit import utils
from cinderclient.tests.unit.v3 import fakes
cs = fakes.FakeClient()
@ddt.ddt
class GroupsTest(utils.TestCase):
def test_delete_group(self):
expected = {'delete': {'delete-volumes': True}}
v = cs.groups.list()[0]
grp = v.delete(delete_volumes=True)
self._assert_request_id(grp)
cs.assert_called('POST', '/groups/1234/action', body=expected)
grp = cs.groups.delete('1234', delete_volumes=True)
self._assert_request_id(grp)
cs.assert_called('POST', '/groups/1234/action', body=expected)
grp = cs.groups.delete(v, delete_volumes=True)
self._assert_request_id(grp)
cs.assert_called('POST', '/groups/1234/action', body=expected)
def test_create_group(self):
grp = cs.groups.create('my_group_type', 'type1,type2', name='group')
cs.assert_called('POST', '/groups')
self._assert_request_id(grp)
def test_create_group_with_volume_types(self):
grp = cs.groups.create('my_group_type', 'type1,type2', name='group')
expected = {'group': {'status': 'creating',
'description': None,
'availability_zone': None,
'user_id': None,
'name': 'group',
'group_type': 'my_group_type',
'volume_types': ['type1', 'type2'],
'project_id': None}}
cs.assert_called('POST', '/groups', body=expected)
self._assert_request_id(grp)
@ddt.data(
{'name': 'group2', 'desc': None, 'add': None, 'remove': None},
{'name': None, 'desc': 'group2 desc', 'add': None, 'remove': None},
{'name': None, 'desc': None, 'add': 'uuid1,uuid2', 'remove': None},
{'name': None, 'desc': None, 'add': None, 'remove': 'uuid3,uuid4'},
)
@ddt.unpack
def test_update_group_name(self, name, desc, add, remove):
v = cs.groups.list()[0]
expected = {'group': {'name': name, 'description': desc,
'add_volumes': add, 'remove_volumes': remove}}
grp = v.update(name=name, description=desc,
add_volumes=add, remove_volumes=remove)
cs.assert_called('PUT', '/groups/1234', body=expected)
self._assert_request_id(grp)
grp = cs.groups.update('1234', name=name, description=desc,
add_volumes=add, remove_volumes=remove)
cs.assert_called('PUT', '/groups/1234', body=expected)
self._assert_request_id(grp)
grp = cs.groups.update(v, name=name, description=desc,
add_volumes=add, remove_volumes=remove)
cs.assert_called('PUT', '/groups/1234', body=expected)
self._assert_request_id(grp)
def test_update_group_none(self):
self.assertIsNone(cs.groups.update('1234'))
def test_update_group_no_props(self):
cs.groups.update('1234')
def test_list_group(self):
lst = cs.groups.list()
cs.assert_called('GET', '/groups/detail')
self._assert_request_id(lst)
def test_list_group_detailed_false(self):
lst = cs.groups.list(detailed=False)
cs.assert_called('GET', '/groups')
self._assert_request_id(lst)
def test_list_group_with_search_opts(self):
lst = cs.groups.list(search_opts={'foo': 'bar'})
cs.assert_called('GET', '/groups/detail?foo=bar')
self._assert_request_id(lst)
def test_list_group_with_empty_search_opt(self):
lst = cs.groups.list(
search_opts={'foo': 'bar', 'abc': None}
)
cs.assert_called('GET', '/groups/detail?foo=bar')
self._assert_request_id(lst)
def test_get_group(self):
group_id = '1234'
grp = cs.groups.get(group_id)
cs.assert_called('GET', '/groups/%s' % group_id)
self._assert_request_id(grp)

@ -13,6 +13,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import ddt
import fixtures
import mock
from requests_mock.contrib import fixture as requests_mock_fixture
@ -25,6 +27,7 @@ from cinderclient.tests.unit.v3 import fakes
from cinderclient.tests.unit.fixture_data import keystone_client
@ddt.ddt
@mock.patch.object(client, 'Client', fakes.FakeClient)
class ShellTest(utils.TestCase):
@ -183,3 +186,78 @@ class ShellTest(utils.TestCase):
def test_group_specs_list(self):
self.run_command('--os-volume-api-version 3.11 group-specs-list')
self.assert_called('GET', '/group_types?is_public=None')
def test_create_volume_with_group(self):
self.run_command('--os-volume-api-version 3.13 create --group-id 5678 '
'--volume-type 4321 1')
self.assert_called('GET', '/volumes/1234')
expected = {'volume': {'imageRef': None,
'project_id': None,
'status': 'creating',
'size': 1,
'user_id': None,
'availability_zone': None,
'source_replica': None,
'attach_status': 'detached',
'source_volid': None,
'consistencygroup_id': None,
'group_id': '5678',
'name': None,
'snapshot_id': None,
'metadata': {},
'volume_type': '4321',
'description': None,
'multiattach': False}}
self.assert_called_anytime('POST', '/volumes', expected)
def test_group_list(self):
self.run_command('--os-volume-api-version 3.13 group-list')
self.assert_called_anytime('GET', '/groups/detail')
def test_group_show(self):
self.run_command('--os-volume-api-version 3.13 '
'group-show 1234')
self.assert_called('GET', '/groups/1234')
@ddt.data(True, False)
def test_group_delete(self, delete_vol):
cmd = '--os-volume-api-version 3.13 group-delete 1234'
if delete_vol:
cmd += ' --delete-volumes'
self.run_command(cmd)
expected = {'delete': {'delete-volumes': delete_vol}}
self.assert_called('POST', '/groups/1234/action', expected)
def test_group_create(self):
expected = {'group': {'name': 'test-1',
'description': 'test-1-desc',
'user_id': None,
'project_id': None,
'status': 'creating',
'group_type': 'my_group_type',
'volume_types': ['type1', 'type2'],
'availability_zone': 'zone1'}}
self.run_command('--os-volume-api-version 3.13 '
'group-create --name test-1 '
'--description test-1-desc '
'--availability-zone zone1 '
'my_group_type type1,type2')
self.assert_called_anytime('POST', '/groups', body=expected)
def test_group_update(self):
self.run_command('--os-volume-api-version 3.13 group-update '
'--name group2 --description desc2 '
'--add-volumes uuid1,uuid2 '
'--remove-volumes uuid3,uuid4 '
'1234')
expected = {'group': {'name': 'group2',
'description': 'desc2',
'add_volumes': 'uuid1,uuid2',
'remove_volumes': 'uuid3,uuid4'}}
self.assert_called('PUT', '/groups/1234',
body=expected)
def test_group_update_invalid_args(self):
self.assertRaises(exceptions.ClientException,
self.run_command,
'--os-volume-api-version 3.13 group-update 1234')

@ -1,4 +1,5 @@
# Copyright 2016 FUJITSU LIMITED
# Copyright (c) 2016 EMC Corporation
#
# All Rights Reserved.
#
@ -20,6 +21,8 @@ from cinderclient.tests.unit.v3 import fakes
from cinderclient.v3.volumes import Volume
from cinderclient.v3.volumes import VolumeManager
cs = fakes.FakeClient()
class VolumesTest(utils.TestCase):
@ -40,3 +43,25 @@ class VolumesTest(utils.TestCase):
fake_volume.upload_to_image(False, 'name', 'bare', 'raw',
visibility='public', protected=True)
cs.assert_called_anytime('POST', '/volumes/1234/action', body=expected)
def test_create_volume(self):
vol = cs.volumes.create(1, group_id='1234', volume_type='5678')
expected = {'volume': {'status': 'creating',
'description': None,
'availability_zone': None,
'source_volid': None,
'snapshot_id': None,
'size': 1,
'user_id': None,
'name': None,
'imageRef': None,
'attach_status': 'detached',
'volume_type': '5678',
'project_id': None,
'metadata': {},
'source_replica': None,
'consistencygroup_id': None,
'multiattach': False,
'group_id': '1234'}}
cs.assert_called('POST', '/volumes', body=expected)
self._assert_request_id(vol)

@ -22,6 +22,7 @@ from cinderclient.v3 import cgsnapshots
from cinderclient.v3 import clusters
from cinderclient.v3 import consistencygroups
from cinderclient.v3 import capabilities
from cinderclient.v3 import groups
from cinderclient.v3 import group_types
from cinderclient.v3 import limits
from cinderclient.v3 import pools
@ -86,6 +87,7 @@ class Client(object):
self.clusters = clusters.ClusterManager(self)
self.consistencygroups = consistencygroups.\
ConsistencygroupManager(self)
self.groups = groups.GroupManager(self)
self.cgsnapshots = cgsnapshots.CgsnapshotManager(self)
self.availability_zones = \
availability_zones.AvailabilityZoneManager(self)

139
cinderclient/v3/groups.py Normal file

@ -0,0 +1,139 @@
# Copyright (C) 2016 EMC Corporation.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Group interface (v3 extension)."""
import six
from six.moves.urllib.parse import urlencode
from cinderclient import base
from cinderclient.openstack.common.apiclient import base as common_base
class Group(base.Resource):
"""A Group of volumes."""
def __repr__(self):
return "<Group: %s>" % self.id
def delete(self, delete_volumes=False):
"""Delete this group."""
return self.manager.delete(self, delete_volumes)
def update(self, **kwargs):
"""Update the name or description for this group."""
return self.manager.update(self, **kwargs)
class GroupManager(base.ManagerWithFind):
"""Manage :class:`Group` resources."""
resource_class = Group
def create(self, group_type, volume_types, name=None,
description=None, user_id=None,
project_id=None, availability_zone=None):
"""Creates a group.
:param group_type: Type of the Group
:param volume_types: Types of volume
:param name: Name of the Group
:param description: Description of the Group
:param user_id: User id derived from context
:param project_id: Project id derived from context
:param availability_zone: Availability Zone to use
:rtype: :class:`Group`
"""
body = {'group': {'name': name,
'description': description,
'group_type': group_type,
'volume_types': volume_types.split(','),
'user_id': user_id,
'project_id': project_id,
'availability_zone': availability_zone,
'status': "creating",
}}
return self._create('/groups', body, 'group')
def get(self, group_id):
"""Get a group.
:param group_id: The ID of the group to get.
:rtype: :class:`Group`
"""
return self._get("/groups/%s" % group_id,
"group")
def list(self, detailed=True, search_opts=None):
"""Lists all groups.
:rtype: list of :class:`Group`
"""
if search_opts is None:
search_opts = {}
qparams = {}
for opt, val in six.iteritems(search_opts):
if val:
qparams[opt] = val
query_string = "?%s" % urlencode(qparams) if qparams else ""
detail = ""
if detailed:
detail = "/detail"
return self._list("/groups%s%s" % (detail, query_string),
"groups")
def delete(self, group, delete_volumes=False):
"""Delete a group.
:param group: the :class:`Group` to delete.
:param delete_volumes: delete volumes in the group.
"""
body = {'delete': {'delete-volumes': delete_volumes}}
self.run_hooks('modify_body_for_action', body, 'group')
url = '/groups/%s/action' % base.getid(group)
resp, body = self.api.client.post(url, body=body)
return common_base.TupleWithMeta((resp, body), resp)
def update(self, group, **kwargs):
"""Update the name or description for a group.
:param Group: The :class:`Group` to update.
"""
if not kwargs:
return
body = {"group": kwargs}
return self._update("/groups/%s" %
base.getid(group), body)
def _action(self, action, group, info=None, **kwargs):
"""Perform a group "action."
:param action: an action to be performed on the group
:param group: a group to perform the action on
:param info: details of the action
:param **kwargs: other parameters
"""
body = {action: info}
self.run_hooks('modify_body_for_action', body, **kwargs)
url = '/groups/%s/action' % base.getid(group)
resp, body = self.api.client.post(url, body=body)
return common_base.TupleWithMeta((resp, body), resp)

@ -88,6 +88,11 @@ def _find_consistencygroup(cs, consistencygroup):
return utils.find_resource(cs.consistencygroups, consistencygroup)
def _find_group(cs, group):
"""Gets a group by name or ID."""
return utils.find_resource(cs.groups, group)
def _find_cgsnapshot(cs, cgsnapshot):
"""Gets a cgsnapshot by name or ID."""
return utils.find_resource(cs.cgsnapshots, cgsnapshot)
@ -313,6 +318,7 @@ class CheckSizeArgForCreate(argparse.Action):
setattr(args, self.dest, values)
@utils.service_type('volumev3')
@utils.arg('size',
metavar='<size>',
nargs='?',
@ -325,6 +331,12 @@ class CheckSizeArgForCreate(argparse.Action):
default=None,
help='ID of a consistency group where the new volume belongs to. '
'Default=None.')
@utils.arg('--group-id',
metavar='<group-id>',
default=None,
help='ID of a group where the new volume belongs to. '
'Default=None.',
start_version='3.13')
@utils.arg('--snapshot-id',
metavar='<snapshot-id>',
default=None,
@ -399,9 +411,9 @@ class CheckSizeArgForCreate(argparse.Action):
help=('Allow volume to be attached more than once.'
' Default=False'),
default=False)
@utils.service_type('volumev3')
def do_create(cs, args):
"""Creates a volume."""
# NOTE(thingee): Backwards-compatibility with v1 args
if args.display_name is not None:
args.name = args.display_name
@ -431,8 +443,13 @@ def do_create(cs, args):
# Keep backward compatibility with image_id, favoring explicit ID
image_ref = args.image_id or args.image or args.image_ref
try:
group_id = args.group_id
except AttributeError:
group_id = None
volume = cs.volumes.create(args.size,
args.consisgroup_id,
group_id,
args.snapshot_id,
args.source_volid,
args.name,
@ -1194,7 +1211,8 @@ def do_credentials(cs, args):
_quota_resources = ['volumes', 'snapshots', 'gigabytes',
'backups', 'backup_gigabytes',
'consistencygroups', 'per_volume_gigabytes']
'consistencygroups', 'per_volume_gigabytes',
'groups', ]
_quota_infos = ['Type', 'In_use', 'Reserved', 'Limit']
@ -1296,6 +1314,11 @@ def do_quota_defaults(cs, args):
metavar='<consistencygroups>',
type=int, default=None,
help='The new "consistencygroups" quota value. Default=None.')
@utils.arg('--groups',
metavar='<groups>',
type=int, default=None,
help='The new "groups" quota value. Default=None.',
start_version='3.13')
@utils.arg('--volume-type',
metavar='<volume_type_name>',
default=None,
@ -2596,10 +2619,28 @@ def do_consisgroup_list(cs, args):
utils.print_list(consistencygroups, columns)
@utils.service_type('volumev3')
@api_versions.wraps('3.13')
@utils.arg('--all-tenants',
dest='all_tenants',
metavar='<0|1>',
nargs='?',
type=int,
const=1,
default=0,
help='Shows details for all tenants. Admin only.')
def do_group_list(cs, args):
"""Lists all groups."""
groups = cs.groups.list()
columns = ['ID', 'Status', 'Name']
utils.print_list(groups, columns)
@utils.service_type('volumev3')
@utils.arg('consistencygroup',
metavar='<consistencygroup>',
help='Name or ID of a consistency group.')
@utils.service_type('volumev3')
def do_consisgroup_show(cs, args):
"""Shows details of a consistency group."""
info = dict()
@ -2610,6 +2651,20 @@ def do_consisgroup_show(cs, args):
utils.print_dict(info)
@utils.arg('group',
metavar='<group>',
help='Name or ID of a group.')
@utils.service_type('volumev3')
def do_group_show(cs, args):
"""Shows details of a group."""
info = dict()
group = _find_group(cs, args.group)
info.update(group._info)
info.pop('links', None)
utils.print_dict(info)
@utils.arg('volumetypes',
metavar='<volume-types>',
help='Volume types.')
@ -2642,6 +2697,43 @@ def do_consisgroup_create(cs, args):
utils.print_dict(info)
@utils.service_type('volumev3')
@api_versions.wraps('3.13')
@utils.arg('grouptype',
metavar='<group-type>',
help='Group type.')
@utils.arg('volumetypes',
metavar='<volume-types>',
help='Comma-separated list of volume types.')
@utils.arg('--name',
metavar='<name>',
help='Name of a group.')
@utils.arg('--description',
metavar='<description>',
default=None,
help='Description of a group. Default=None.')
@utils.arg('--availability-zone',
metavar='<availability-zone>',
default=None,
help='Availability zone for group. Default=None.')
def do_group_create(cs, args):
"""Creates a group."""
group = cs.groups.create(
args.grouptype,
args.volumetypes,
args.name,
args.description,
availability_zone=args.availability_zone)
info = dict()
group = cs.groups.get(group.id)
info.update(group._info)
info.pop('links', None)
utils.print_dict(info)
@utils.arg('--cgsnapshot',
metavar='<cgsnapshot>',
help='Name or ID of a cgsnapshot. Default=None.')
@ -2709,6 +2801,36 @@ def do_consisgroup_delete(cs, args):
"consistency groups.")
@utils.service_type('volumev3')
@api_versions.wraps('3.13')
@utils.arg('group',
metavar='<group>', nargs='+',
help='Name or ID of one or more groups '
'to be deleted.')
@utils.arg('--delete-volumes',
action='store_true',
default=False,
help='Allows or disallows groups to be deleted '
'if they are not empty. If the group is empty, '
'it can be deleted without the delete-volumes flag. '
'If the group is not empty, the delete-volumes '
'flag is required for it to be deleted. If True, '
'all volumes in the group will also be deleted.')
def do_group_delete(cs, args):
"""Removes one or more groups."""
failure_count = 0
for group in args.group:
try:
_find_group(cs, group).delete(args.delete_volumes)
except Exception as e:
failure_count += 1
print("Delete for group %s failed: %s" %
(group, e))
if failure_count == len(args.group):
raise exceptions.CommandError("Unable to delete any of the specified "
"groups.")
@utils.arg('consistencygroup',
metavar='<consistencygroup>',
help='Name or ID of a consistency group.')
@ -2751,6 +2873,49 @@ def do_consisgroup_update(cs, args):
_find_consistencygroup(cs, args.consistencygroup).update(**kwargs)
@utils.service_type('volumev3')
@api_versions.wraps('3.13')
@utils.arg('group',
metavar='<group>',
help='Name or ID of a group.')
@utils.arg('--name', metavar='<name>',
help='New name for group. Default=None.')
@utils.arg('--description', metavar='<description>',
help='New description for group. Default=None.')
@utils.arg('--add-volumes',
metavar='<uuid1,uuid2,......>',
help='UUID of one or more volumes '
'to be added to the group, '
'separated by commas. Default=None.')
@utils.arg('--remove-volumes',
metavar='<uuid3,uuid4,......>',
help='UUID of one or more volumes '
'to be removed from the group, '
'separated by commas. Default=None.')
def do_group_update(cs, args):
"""Updates a group."""
kwargs = {}
if args.name is not None:
kwargs['name'] = args.name
if args.description is not None:
kwargs['description'] = args.description
if args.add_volumes is not None:
kwargs['add_volumes'] = args.add_volumes
if args.remove_volumes is not None:
kwargs['remove_volumes'] = args.remove_volumes
if not kwargs:
msg = ('At least one of the following args must be supplied: '
'name, description, add-volumes, remove-volumes.')
raise exceptions.ClientException(code=1, message=msg)
_find_group(cs, args.group).update(**kwargs)
@utils.arg('--all-tenants',
dest='all_tenants',
metavar='<0|1>',

@ -214,7 +214,8 @@ class VolumeManager(base.ManagerWithFind):
"""Manage :class:`Volume` resources."""
resource_class = Volume
def create(self, size, consistencygroup_id=None, snapshot_id=None,
def create(self, size, consistencygroup_id=None,
group_id=None, snapshot_id=None,
source_volid=None, name=None, description=None,
volume_type=None, user_id=None,
project_id=None, availability_zone=None,
@ -224,6 +225,7 @@ class VolumeManager(base.ManagerWithFind):
:param size: Size of volume in GB
:param consistencygroup_id: ID of the consistencygroup
:param group_id: ID of the group
:param snapshot_id: ID of the snapshot
:param name: Name of the volume
:param description: Description of the volume
@ -264,6 +266,9 @@ class VolumeManager(base.ManagerWithFind):
'multiattach': multiattach,
}}
if group_id:
body['volume']['group_id'] = group_id
if scheduler_hints:
body['OS-SCH-HNT:scheduler_hints'] = scheduler_hints