From 661a4f121261569e1ce73c3ba19811db2342762d Mon Sep 17 00:00:00 2001 From: Ryan Liang Date: Wed, 30 May 2018 16:19:12 +0800 Subject: [PATCH] Unity: Add consistent group support Users could create a group type supporting consistent groups with specification `'consistent_group_snapshot_enabled': True`, then any groups created of that group type are consistent groups, otherwise they are generic groups. The supported operations are: - Create/delete consistent groups - Add volumes to and remove volumes from consistent groups - Create/delete consistent group snapshots - Create consistent groups from snapshots - Clone consistent groups This change also does some refactor and puts extra capabilities report together in `utils.py`, including the existing `thin_provisioning_support`, `thick_provisioning_support` and the newly one added for cg named `consistent_group_snapshot_enabled`. Implements: blueprint unity-consistent-group-support Change-Id: I0ef2ec959f892acb79d8d08a31d9a8ad47c4350f --- .../drivers/dell_emc/unity/fake_exception.py | 4 + .../drivers/dell_emc/unity/test_adapter.py | 297 +++++++++++++++++- .../drivers/dell_emc/unity/test_client.py | 157 +++++++++ .../drivers/dell_emc/unity/test_driver.py | 143 +++++++++ .../volume/drivers/dell_emc/unity/adapter.py | 147 +++++++-- .../volume/drivers/dell_emc/unity/client.py | 47 ++- .../volume/drivers/dell_emc/unity/driver.py | 62 +++- cinder/volume/drivers/dell_emc/unity/utils.py | 16 + .../drivers/dell-emc-unity-driver.rst | 25 ++ doc/source/reference/support-matrix.ini | 2 +- .../notes/support-cg-2b55da0bd9f69c7d.yaml | 11 + 11 files changed, 883 insertions(+), 28 deletions(-) create mode 100644 releasenotes/notes/support-cg-2b55da0bd9f69c7d.yaml diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py index 81db860e245..6fa74ba8fcd 100644 --- a/cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py @@ -92,3 +92,7 @@ class UnityThinCloneNotAllowedError(StoropsException): class SystemAPINotSupported(StoropsException): pass + + +class UnityConsistencyGroupNameInUseError(StoropsException): + pass diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py index c83f829ef56..bf3c7a89aeb 100644 --- a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py @@ -16,6 +16,7 @@ import contextlib import functools +import ddt import mock from oslo_utils import units @@ -375,7 +376,10 @@ class IdMatcher(object): # ######################## +@ddt.ddt @mock.patch.object(adapter, 'storops_ex', new=ex) +@mock.patch.object(adapter.vol_utils, 'is_group_a_cg_snapshot_type', + new=lambda x: True) class CommonAdapterTest(test.TestCase): def setUp(self): super(CommonAdapterTest, self).setUp() @@ -389,7 +393,8 @@ class CommonAdapterTest(test.TestCase): @patch_for_unity_adapter def test_create_volume(self): - volume = MockOSResource(name='lun_3', size=5, host='unity#pool1') + volume = MockOSResource(name='lun_3', size=5, host='unity#pool1', + group=None) ret = self.adapter.create_volume(volume) expected = get_lun_pl('lun_3') self.assertEqual(expected, ret['provider_location']) @@ -397,7 +402,7 @@ class CommonAdapterTest(test.TestCase): @patch_for_unity_adapter def test_create_volume_thick(self): volume = MockOSResource(name='lun_3', size=5, host='unity#pool1', - volume_type_id='thick') + group=None, volume_type_id='thick') ret = self.adapter.create_volume(volume) expected = get_lun_pl('lun_3_thick') @@ -408,7 +413,7 @@ class CommonAdapterTest(test.TestCase): volume_type = MockOSResource( extra_specs={'compression_support': ' True'}) volume = MockOSResource(name='lun_3', size=5, host='unity#pool1', - volume_type=volume_type) + group=None, volume_type=volume_type) ret = self.adapter.create_volume(volume) expected = get_lun_pl('lun_3') self.assertEqual(expected, ret['provider_location']) @@ -454,6 +459,7 @@ class CommonAdapterTest(test.TestCase): self.assertTrue(stats['thick_provisioning_support']) self.assertTrue(stats['thin_provisioning_support']) self.assertTrue(stats['compression_support']) + self.assertTrue(stats['consistent_group_snapshot_enabled']) def test_update_volume_stats(self): stats = self.adapter.update_volume_stats() @@ -461,6 +467,7 @@ class CommonAdapterTest(test.TestCase): self.assertEqual('unknown', stats['storage_protocol']) self.assertTrue(stats['thin_provisioning_support']) self.assertTrue(stats['thick_provisioning_support']) + self.assertTrue(stats['consistent_group_snapshot_enabled']) self.assertEqual(1, len(stats['pools'])) def test_serial_number(self): @@ -678,6 +685,7 @@ class CommonAdapterTest(test.TestCase): volume = MockOSResource(name=lun_id, id=lun_id, host='unity#pool1', provider_location=get_lun_pl(lun_id)) src_snap = test_client.MockResource(name=src_snap_id, _id=src_snap_id) + src_snap.size = 5 * units.Gi src_snap.storage_resource = test_client.MockResource(name=src_lun_id, _id=src_lun_id) with patch_copy_volume() as copy_volume: @@ -841,6 +849,289 @@ class CommonAdapterTest(test.TestCase): ret = self.adapter.migrate_volume(volume, host) self.assertEqual((False, None), ret) + @ddt.unpack + @ddt.data((('group-1', 'group-1_name', 'group-1_description'), + ('group-1', 'group-1_description')), + (('group-2', 'group-2_name', None), ('group-2', 'group-2_name')), + (('group-3', 'group-3_name', ''), ('group-3', 'group-3_name'))) + def test_create_group(self, inputs, expected): + cg_id, cg_name, cg_description = inputs + cg = MockOSResource(id=cg_id, name=cg_name, description=cg_description) + with mock.patch.object(self.adapter.client, 'create_cg', + create=True) as mocked: + model_update = self.adapter.create_group(cg) + self.assertEqual('available', model_update['status']) + mocked.assert_called_once_with(expected[0], + description=expected[1]) + + def test_delete_group(self): + cg = MockOSResource(id='group-1') + with mock.patch.object(self.adapter.client, 'delete_cg', + create=True) as mocked: + ret = self.adapter.delete_group(cg) + self.assertIsNone(ret[0]) + self.assertIsNone(ret[1]) + mocked.assert_called_once_with('group-1') + + def test_update_group(self): + cg = MockOSResource(id='group-1') + add_volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-1', 'sv_1'), + ('volume-2', 'sv_2'))] + remove_volumes = [MockOSResource( + id='volume-3', provider_location=get_lun_pl('sv_3'))] + with mock.patch.object(self.adapter.client, 'update_cg', + create=True) as mocked: + ret = self.adapter.update_group(cg, add_volumes, remove_volumes) + self.assertEqual('available', ret[0]['status']) + self.assertIsNone(ret[1]) + self.assertIsNone(ret[2]) + mocked.assert_called_once_with('group-1', {'sv_1', 'sv_2'}, + {'sv_3'}) + + def test_update_group_add_volumes_none(self): + cg = MockOSResource(id='group-1') + remove_volumes = [MockOSResource( + id='volume-3', provider_location=get_lun_pl('sv_3'))] + with mock.patch.object(self.adapter.client, 'update_cg', + create=True) as mocked: + ret = self.adapter.update_group(cg, None, remove_volumes) + self.assertEqual('available', ret[0]['status']) + self.assertIsNone(ret[1]) + self.assertIsNone(ret[2]) + mocked.assert_called_once_with('group-1', set(), {'sv_3'}) + + def test_update_group_remove_volumes_none(self): + cg = MockOSResource(id='group-1') + add_volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-1', 'sv_1'), + ('volume-2', 'sv_2'))] + with mock.patch.object(self.adapter.client, 'update_cg', + create=True) as mocked: + ret = self.adapter.update_group(cg, add_volumes, None) + self.assertEqual('available', ret[0]['status']) + self.assertIsNone(ret[1]) + self.assertIsNone(ret[2]) + mocked.assert_called_once_with('group-1', {'sv_1', 'sv_2'}, set()) + + def test_update_group_add_remove_volumes_none(self): + cg = MockOSResource(id='group-1') + with mock.patch.object(self.adapter.client, 'update_cg', + create=True) as mocked: + ret = self.adapter.update_group(cg, None, None) + self.assertEqual('available', ret[0]['status']) + self.assertIsNone(ret[1]) + self.assertIsNone(ret[2]) + mocked.assert_called_once_with('group-1', set(), set()) + + @patch_for_unity_adapter + def test_copy_luns_in_group(self): + cg = MockOSResource(id='group-1') + volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-3', 'sv_3'), + ('volume-4', 'sv_4'))] + src_cg_snap = test_client.MockResource(_id='id_src_cg_snap') + src_volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-1', 'sv_1'), + ('volume-2', 'sv_2'))] + copied_luns = [test_client.MockResource(_id=lun_id) + for lun_id in ('sv_3', 'sv_4')] + + def _prepare_lun_snaps(lun_id): + lun_snap = test_client.MockResource(_id='snap_{}'.format(lun_id)) + lun_snap.lun = test_client.MockResource(_id=lun_id) + return lun_snap + + lun_snaps = list(map(_prepare_lun_snaps, ('sv_1', 'sv_2'))) + with mock.patch.object(self.adapter.client, 'filter_snaps_in_cg_snap', + create=True) as mocked_filter, \ + mock.patch.object(self.adapter.client, 'create_cg', + create=True) as mocked_create_cg, \ + patch_dd_copy(None) as mocked_dd: + mocked_filter.return_value = lun_snaps + mocked_dd.side_effect = copied_luns + + ret = self.adapter.copy_luns_in_group(cg, volumes, src_cg_snap, + src_volumes) + + mocked_filter.assert_called_once_with('id_src_cg_snap') + dd_args = zip([adapter.VolumeParams(self.adapter, vol) + for vol in volumes], + lun_snaps) + mocked_dd.assert_has_calls([mock.call(*args) for args in dd_args]) + mocked_create_cg.assert_called_once_with('group-1', + lun_add=copied_luns) + self.assertEqual('available', ret[0]['status']) + self.assertEqual(2, len(ret[1])) + for vol_id in ('volume-3', 'volume-4'): + self.assertIn({'id': vol_id, 'status': 'available'}, ret[1]) + + def test_create_group_from_snap(self): + cg = MockOSResource(id='group-2') + volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-3', 'sv_3'), + ('volume-4', 'sv_4'))] + cg_snap = MockOSResource(id='snap-group-1') + vol_1 = MockOSResource(id='volume-1') + vol_2 = MockOSResource(id='volume-2') + vol_snaps = [MockOSResource(id='snap-volume-1', volume=vol_1), + MockOSResource(id='snap-volume-2', volume=vol_2)] + + src_cg_snap = test_client.MockResource(_id='id_src_cg_snap') + with mock.patch.object(self.adapter.client, 'get_snap', + create=True, return_value=src_cg_snap), \ + mock.patch.object(self.adapter, 'copy_luns_in_group', + create=True) as mocked_copy: + mocked_copy.return_value = ({'status': 'available'}, + [{'id': 'volume-3', + 'status': 'available'}, + {'id': 'volume-4', + 'status': 'available'}]) + ret = self.adapter.create_group_from_snap(cg, volumes, cg_snap, + vol_snaps) + + mocked_copy.assert_called_once_with(cg, volumes, src_cg_snap, + [vol_1, vol_2]) + self.assertEqual('available', ret[0]['status']) + self.assertEqual(2, len(ret[1])) + for vol_id in ('volume-3', 'volume-4'): + self.assertIn({'id': vol_id, 'status': 'available'}, ret[1]) + + def test_create_group_from_snap_none_snapshots(self): + cg = MockOSResource(id='group-2') + volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-3', 'sv_3'), + ('volume-4', 'sv_4'))] + cg_snap = MockOSResource(id='snap-group-1') + + src_cg_snap = test_client.MockResource(_id='id_src_cg_snap') + with mock.patch.object(self.adapter.client, 'get_snap', + create=True, return_value=src_cg_snap), \ + mock.patch.object(self.adapter, 'copy_luns_in_group', + create=True) as mocked_copy: + mocked_copy.return_value = ({'status': 'available'}, + [{'id': 'volume-3', + 'status': 'available'}, + {'id': 'volume-4', + 'status': 'available'}]) + ret = self.adapter.create_group_from_snap(cg, volumes, cg_snap, + None) + + mocked_copy.assert_called_once_with(cg, volumes, src_cg_snap, []) + self.assertEqual('available', ret[0]['status']) + self.assertEqual(2, len(ret[1])) + for vol_id in ('volume-3', 'volume-4'): + self.assertIn({'id': vol_id, 'status': 'available'}, ret[1]) + + def test_create_cloned_group(self): + cg = MockOSResource(id='group-2') + volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-3', 'sv_3'), + ('volume-4', 'sv_4'))] + src_cg = MockOSResource(id='group-1') + vol_1 = MockOSResource(id='volume-1') + vol_2 = MockOSResource(id='volume-2') + src_vols = [vol_1, vol_2] + + src_cg_snap = test_client.MockResource(_id='id_src_cg_snap') + with mock.patch.object(self.adapter.client, 'create_cg_snap', + create=True, + return_value=src_cg_snap) as mocked_create, \ + mock.patch.object(self.adapter, 'copy_luns_in_group', + create=True) as mocked_copy: + mocked_create.__name__ = 'create_cg_snap' + mocked_copy.return_value = ({'status': 'available'}, + [{'id': 'volume-3', + 'status': 'available'}, + {'id': 'volume-4', + 'status': 'available'}]) + ret = self.adapter.create_cloned_group(cg, volumes, src_cg, + src_vols) + + mocked_create.assert_called_once_with('group-1', + 'snap_clone_group_group-1') + + mocked_copy.assert_called_once_with(cg, volumes, src_cg_snap, + [vol_1, vol_2]) + self.assertEqual('available', ret[0]['status']) + self.assertEqual(2, len(ret[1])) + for vol_id in ('volume-3', 'volume-4'): + self.assertIn({'id': vol_id, 'status': 'available'}, ret[1]) + + def test_create_cloned_group_none_source_vols(self): + cg = MockOSResource(id='group-2') + volumes = [MockOSResource(id=vol_id, + provider_location=get_lun_pl(lun_id)) + for vol_id, lun_id in (('volume-3', 'sv_3'), + ('volume-4', 'sv_4'))] + src_cg = MockOSResource(id='group-1') + + src_cg_snap = test_client.MockResource(_id='id_src_cg_snap') + with mock.patch.object(self.adapter.client, 'create_cg_snap', + create=True, + return_value=src_cg_snap) as mocked_create, \ + mock.patch.object(self.adapter, 'copy_luns_in_group', + create=True) as mocked_copy: + mocked_create.__name__ = 'create_cg_snap' + mocked_copy.return_value = ({'status': 'available'}, + [{'id': 'volume-3', + 'status': 'available'}, + {'id': 'volume-4', + 'status': 'available'}]) + ret = self.adapter.create_cloned_group(cg, volumes, src_cg, + None) + + mocked_create.assert_called_once_with('group-1', + 'snap_clone_group_group-1') + + mocked_copy.assert_called_once_with(cg, volumes, src_cg_snap, []) + self.assertEqual('available', ret[0]['status']) + self.assertEqual(2, len(ret[1])) + for vol_id in ('volume-3', 'volume-4'): + self.assertIn({'id': vol_id, 'status': 'available'}, ret[1]) + + def test_create_group_snapshot(self): + cg_snap = MockOSResource(id='snap-group-1', group_id='group-1') + vol_1 = MockOSResource(id='volume-1') + vol_2 = MockOSResource(id='volume-2') + vol_snaps = [MockOSResource(id='snap-volume-1', volume=vol_1), + MockOSResource(id='snap-volume-2', volume=vol_2)] + with mock.patch.object(self.adapter.client, 'create_cg_snap', + create=True) as mocked_create: + mocked_create.return_value = ({'status': 'available'}, + [{'id': 'snap-volume-1', + 'status': 'available'}, + {'id': 'snap-volume-2', + 'status': 'available'}]) + ret = self.adapter.create_group_snapshot(cg_snap, vol_snaps) + + mocked_create.assert_called_once_with('group-1', + snap_name='snap-group-1') + self.assertEqual({'status': 'available'}, ret[0]) + self.assertEqual(2, len(ret[1])) + for snap_id in ('snap-volume-1', 'snap-volume-2'): + self.assertIn({'id': snap_id, 'status': 'available'}, ret[1]) + + def test_delete_group_snapshot(self): + group_snap = MockOSResource(id='snap-group-1') + cg_snap = test_client.MockResource(_id='snap_cg_1') + with mock.patch.object(self.adapter.client, 'get_snap', + create=True, + return_value=cg_snap) as mocked_get, \ + mock.patch.object(self.adapter.client, 'delete_snap', + create=True) as mocked_delete: + ret = self.adapter.delete_group_snapshot(group_snap) + mocked_get.assert_called_once_with('snap-group-1') + mocked_delete.assert_called_once_with(cg_snap) + self.assertEqual((None, None), ret) + class FCAdapterTest(test.TestCase): def setUp(self): diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py index 8a3a5c6d7ce..62cbba8167a 100644 --- a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py @@ -14,6 +14,7 @@ # under the License. import unittest +import ddt from mock import mock from oslo_utils import units @@ -22,6 +23,7 @@ from cinder.tests.unit.volume.drivers.dell_emc.unity \ import fake_exception as ex from cinder.volume.drivers.dell_emc.unity import client + ######################## # # Start of Mocks @@ -50,6 +52,9 @@ class MockResource(object): self.host_cache = [] self.is_thin = None self.is_all_flash = True + self.description = None + self.luns = None + self.lun = None @property def id(self): @@ -220,6 +225,14 @@ class MockResourceList(object): def name(self): return map(lambda i: i.name, self.resources) + @property + def list(self): + return self.resources + + @list.setter + def list(self, value): + self.resources = [] + def __iter__(self): return self.resources.__iter__() @@ -327,6 +340,7 @@ def get_client(): # Start of Tests # ######################## +@ddt.ddt @mock.patch.object(client, 'storops_ex', new=ex) class ClientTest(unittest.TestCase): def setUp(self): @@ -591,3 +605,146 @@ class ClientTest(unittest.TestCase): self.client.host_cache['empty-host-in-cache'] = host self.client.delete_host_wo_lock(host) self.assertNotIn(host.name, self.client.host_cache) + + @ddt.data(('cg_1', 'cg_1_description', [MockResource(_id='sv_1')]), + ('cg_2', None, None), + ('cg_3', None, [MockResource(_id='sv_2')]), + ('cg_4', 'cg_4_description', None)) + @ddt.unpack + def test_create_cg(self, cg_name, cg_description, lun_add): + created_cg = MockResource(_id='cg_1') + with mock.patch.object(self.client.system, 'create_cg', + create=True, return_value=created_cg + ) as mocked_create: + ret = self.client.create_cg(cg_name, description=cg_description, + lun_add=lun_add) + mocked_create.assert_called_once_with(cg_name, + description=cg_description, + lun_add=lun_add) + self.assertEqual(created_cg, ret) + + def test_create_cg_existing_name(self): + existing_cg = MockResource(_id='cg_1') + with mock.patch.object( + self.client.system, 'create_cg', + side_effect=ex.UnityConsistencyGroupNameInUseError, + create=True) as mocked_create, \ + mock.patch.object(self.client.system, 'get_cg', + create=True, + return_value=existing_cg) as mocked_get: + ret = self.client.create_cg('existing_name') + mocked_create.assert_called_once_with('existing_name', + description=None, + lun_add=None) + mocked_get.assert_called_once_with(name='existing_name') + self.assertEqual(existing_cg, ret) + + def test_get_cg(self): + existing_cg = MockResource(_id='cg_1') + with mock.patch.object(self.client.system, 'get_cg', + create=True, + return_value=existing_cg) as mocked_get: + ret = self.client.get_cg('existing_name') + mocked_get.assert_called_once_with(name='existing_name') + self.assertEqual(existing_cg, ret) + + def test_get_cg_not_found(self): + with mock.patch.object(self.client.system, 'get_cg', + create=True, + side_effect=ex.UnityResourceNotFoundError + ) as mocked_get: + ret = self.client.get_cg('not_found_name') + mocked_get.assert_called_once_with(name='not_found_name') + self.assertIsNone(ret) + + def test_delete_cg(self): + existing_cg = MockResource(_id='cg_1') + with mock.patch.object(existing_cg, 'delete', create=True + ) as mocked_delete, \ + mock.patch.object(self.client, 'get_cg', + create=True, + return_value=existing_cg) as mocked_get: + ret = self.client.delete_cg('cg_1_name') + mocked_get.assert_called_once_with('cg_1_name') + mocked_delete.assert_called_once() + self.assertIsNone(ret) + + def test_update_cg(self): + existing_cg = MockResource(_id='cg_1') + lun_1 = MockResource(_id='sv_1') + lun_2 = MockResource(_id='sv_2') + lun_3 = MockResource(_id='sv_3') + + def _mocked_get_lun(lun_id): + if lun_id == 'sv_1': + return lun_1 + if lun_id == 'sv_2': + return lun_2 + if lun_id == 'sv_3': + return lun_3 + + with mock.patch.object(existing_cg, 'update_lun', create=True + ) as mocked_update, \ + mock.patch.object(self.client, 'get_cg', + create=True, + return_value=existing_cg) as mocked_get, \ + mock.patch.object(self.client, 'get_lun', + side_effect=_mocked_get_lun): + ret = self.client.update_cg('cg_1_name', ['sv_1', 'sv_2'], + ['sv_3']) + mocked_get.assert_called_once_with('cg_1_name') + mocked_update.assert_called_once_with(add_luns=[lun_1, lun_2], + remove_luns=[lun_3]) + self.assertIsNone(ret) + + def test_update_cg_empty_lun_ids(self): + existing_cg = MockResource(_id='cg_1') + with mock.patch.object(existing_cg, 'update_lun', create=True + ) as mocked_update, \ + mock.patch.object(self.client, 'get_cg', + create=True, + return_value=existing_cg) as mocked_get: + ret = self.client.update_cg('cg_1_name', set(), set()) + mocked_get.assert_called_once_with('cg_1_name') + mocked_update.assert_called_once_with(add_luns=[], remove_luns=[]) + self.assertIsNone(ret) + + def test_create_cg_group(self): + existing_cg = MockResource(_id='cg_1') + created_snap = MockResource(_id='snap_cg_1', name='snap_name_cg_1') + with mock.patch.object(existing_cg, 'create_snap', create=True, + return_value=created_snap) as mocked_create, \ + mock.patch.object(self.client, 'get_cg', + create=True, + return_value=existing_cg) as mocked_get: + ret = self.client.create_cg_snap('cg_1_name', + snap_name='snap_name_cg_1') + mocked_get.assert_called_once_with('cg_1_name') + mocked_create.assert_called_once_with(name='snap_name_cg_1', + is_auto_delete=False) + self.assertEqual(created_snap, ret) + + def test_create_cg_group_none_name(self): + existing_cg = MockResource(_id='cg_1') + created_snap = MockResource(_id='snap_cg_1') + with mock.patch.object(existing_cg, 'create_snap', create=True, + return_value=created_snap) as mocked_create, \ + mock.patch.object(self.client, 'get_cg', + create=True, + return_value=existing_cg) as mocked_get: + ret = self.client.create_cg_snap('cg_1_name') + mocked_get.assert_called_once_with('cg_1_name') + mocked_create.assert_called_once_with(name=None, + is_auto_delete=False) + self.assertEqual(created_snap, ret) + + def test_filter_snaps_in_cg_snap(self): + snaps = [MockResource(_id='snap_{}'.format(n)) for n in (1, 2)] + snap_list = mock.MagicMock() + snap_list.list = snaps + with mock.patch.object(self.client.system, 'get_snap', + create=True, + return_value=snap_list) as mocked_get: + ret = self.client.filter_snaps_in_cg_snap('snap_cg_1') + mocked_get.assert_called_once_with(snap_group='snap_cg_1') + self.assertEqual(snaps, ret) diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py index eb20db77cd7..6b2eedc5886 100644 --- a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py @@ -13,8 +13,11 @@ # License for the specific language governing permissions and limitations # under the License. +import functools import unittest +import mock + from cinder.tests.unit.volume.drivers.dell_emc.unity \ import fake_exception as ex from cinder.tests.unit.volume.drivers.dell_emc.unity import test_adapter @@ -104,6 +107,34 @@ class MockAdapter(object): def migrate_volume(volume, host): return True, {} + @staticmethod + def create_group(group): + return group + + @staticmethod + def delete_group(group): + return group + + @staticmethod + def update_group(group, add_volumes, remove_volumes): + return group, add_volumes, remove_volumes + + @staticmethod + def create_group_from_snap(group, volumes, group_snapshot, snapshots): + return group, volumes, group_snapshot, snapshots + + @staticmethod + def create_cloned_group(group, volumes, source_group, source_vols): + return group, volumes, source_group, source_vols + + @staticmethod + def create_group_snapshot(group_snapshot, snapshots): + return group_snapshot, snapshots + + @staticmethod + def delete_group_snapshot(group_snapshot): + return group_snapshot + ######################## # @@ -112,16 +143,41 @@ class MockAdapter(object): ######################## +patch_check_cg = mock.patch( + 'cinder.volume.utils.is_group_a_cg_snapshot_type', + side_effect=lambda g: not g.id.endswith('_generic')) + + class UnityDriverTest(unittest.TestCase): @staticmethod def get_volume(): return test_adapter.MockOSResource(provider_location='id^lun_43', id='id_43') + @staticmethod + def get_generic_group(): + return test_adapter.MockOSResource(name='group_name_generic', + id='group_id_generic') + + @staticmethod + def get_cg(): + return test_adapter.MockOSResource(name='group_name_cg', + id='group_id_cg') + @classmethod def get_snapshot(cls): return test_adapter.MockOSResource(volume=cls.get_volume()) + @classmethod + def get_generic_group_snapshot(cls): + return test_adapter.MockOSResource(group=cls.get_generic_group(), + id='group_snapshot_id_generic') + + @classmethod + def get_cg_group_snapshot(cls): + return test_adapter.MockOSResource(group=cls.get_cg(), + id='group_snapshot_id_cg') + @staticmethod def get_context(): return None @@ -279,3 +335,90 @@ class UnityDriverTest(unittest.TestCase): volume = self.get_volume() r = self.driver.revert_to_snapshot(None, volume, snapshot) self.assertTrue(r) + + @patch_check_cg + def test_operate_generic_group_not_implemented(self, _): + group = self.get_generic_group() + context = self.get_context() + + for func in (self.driver.create_group, self.driver.update_group): + self.assertRaises(NotImplementedError, + functools.partial(func, context, group)) + + volumes = [self.get_volume()] + for func in (self.driver.delete_group, + self.driver.create_group_from_src): + self.assertRaises(NotImplementedError, + functools.partial(func, context, group, volumes)) + + group_snap = self.get_generic_group_snapshot() + volume_snaps = [self.get_snapshot()] + for func in (self.driver.create_group_snapshot, + self.driver.delete_group_snapshot): + self.assertRaises(NotImplementedError, + functools.partial(func, context, group_snap, + volume_snaps)) + + @patch_check_cg + def test_create_group_cg(self, _): + cg = self.get_cg() + ret = self.driver.create_group(self.get_context(), cg) + self.assertEqual(ret, cg) + + @patch_check_cg + def test_delete_group_cg(self, _): + cg = self.get_cg() + volumes = [self.get_volume()] + ret = self.driver.delete_group(self.get_context(), cg, volumes) + self.assertEqual(ret, cg) + + @patch_check_cg + def test_update_group_cg(self, _): + cg = self.get_cg() + volumes = [self.get_volume()] + ret = self.driver.update_group(self.get_context(), cg, + add_volumes=volumes) + self.assertEqual(ret[0], cg) + self.assertListEqual(ret[1], volumes) + self.assertIsNone(ret[2]) + + @patch_check_cg + def test_create_group_from_src_group(self, _): + cg = self.get_cg() + volumes = [self.get_volume()] + source_group = cg + ret = self.driver.create_group_from_src(self.get_context(), cg, + volumes, + source_group=source_group) + self.assertEqual(ret[0], cg) + self.assertListEqual(ret[1], volumes) + self.assertEqual(ret[2], source_group) + self.assertIsNone(ret[3]) + + @patch_check_cg + def test_create_group_from_src_group_snapshot(self, _): + cg = self.get_cg() + volumes = [self.get_volume()] + cg_snap = self.get_cg_group_snapshot() + ret = self.driver.create_group_from_src(self.get_context(), cg, + volumes, + group_snapshot=cg_snap) + self.assertEqual(ret[0], cg) + self.assertListEqual(ret[1], volumes) + self.assertEqual(ret[2], cg_snap) + self.assertIsNone(ret[3]) + + @patch_check_cg + def test_create_group_snapshot_cg(self, _): + cg_snap = self.get_cg_group_snapshot() + ret = self.driver.create_group_snapshot(self.get_context(), cg_snap, + None) + self.assertEqual(ret[0], cg_snap) + self.assertIsNone(ret[1]) + + @patch_check_cg + def test_delete_group_snapshot_cg(self, _): + cg_snap = self.get_cg_group_snapshot() + ret = self.driver.delete_group_snapshot(self.get_context(), cg_snap, + None) + self.assertEqual(ret, cg_snap) diff --git a/cinder/volume/drivers/dell_emc/unity/adapter.py b/cinder/volume/drivers/dell_emc/unity/adapter.py index 36d8284898f..755eb8fcf13 100644 --- a/cinder/volume/drivers/dell_emc/unity/adapter.py +++ b/cinder/volume/drivers/dell_emc/unity/adapter.py @@ -26,6 +26,7 @@ from oslo_utils import importutils from cinder import exception from cinder.i18n import _ +from cinder.objects import fields from cinder import utils as cinder_utils from cinder.volume.drivers.dell_emc.unity import client from cinder.volume.drivers.dell_emc.unity import utils @@ -59,6 +60,7 @@ class VolumeParams(object): self._io_limit_policy = None self._is_thick = None self._is_compressed = None + self._is_in_cg = None @property def volume_id(self): @@ -133,13 +135,29 @@ class VolumeParams(object): def is_compressed(self, value): self._is_compressed = value + @property + def is_in_cg(self): + if self._is_in_cg is None: + self._is_in_cg = (self._volume.group and + vol_utils.is_group_a_cg_snapshot_type( + self._volume.group)) + return self._is_in_cg + + @property + def cg_id(self): + if self.is_in_cg: + return self._volume.group_id + return None + def __eq__(self, other): return (self.volume_id == other.volume_id and self.name == other.name and self.size == other.size and self.io_limit_policy == other.io_limit_policy and self.is_thick == other.is_thick and - self.is_compressed == other.is_compressed) + self.is_compressed == other.is_compressed and + self.is_in_cg == other.is_in_cg and + self.cg_id == other.cg_id) class CommonAdapter(object): @@ -302,22 +320,30 @@ class CommonAdapter(object): 'pool': params.pool, 'io_limit_policy': params.io_limit_policy, 'is_thick': params.is_thick, - 'is_compressed': params.is_compressed + 'is_compressed': params.is_compressed, + 'cg_id': params.cg_id } LOG.info('Create Volume: %(name)s, size: %(size)s, description: ' '%(description)s, pool: %(pool)s, io limit policy: ' '%(io_limit_policy)s, thick: %(is_thick)s, ' - '%(is_compressed)s.', log_params) + 'compressed: %(is_compressed)s, cg_group: %(cg_id)s.', + log_params) - return self.makeup_model( - self.client.create_lun(name=params.name, - size=params.size, - pool=params.pool, - description=params.description, - io_limit_policy=params.io_limit_policy, - is_thin=False if params.is_thick else None, - is_compressed=params.is_compressed)) + lun = self.client.create_lun( + name=params.name, + size=params.size, + pool=params.pool, + description=params.description, + io_limit_policy=params.io_limit_policy, + is_thin=False if params.is_thick else None, + is_compressed=params.is_compressed) + if params.cg_id: + LOG.debug('Adding lun %(lun)s to cg %(cg)s.', + {'lun': lun.get_id(), 'cg': params.cg_id}) + self.client.update_cg(params.cg_id, [lun.get_id()], ()) + + return self.makeup_model(lun) def delete_volume(self, volume): lun_id = self.get_lun_id(volume) @@ -442,12 +468,11 @@ class CommonAdapter(object): lun_id=lun_id, version=self.version) + @utils.append_capabilities def update_volume_stats(self): return { 'volume_backend_name': self.volume_backend_name, 'storage_protocol': self.protocol, - 'thin_provisioning_support': True, - 'thick_provisioning_support': True, 'pools': self.get_pools_stats(), } @@ -459,6 +484,7 @@ class CommonAdapter(object): def pools(self): return self.storage_pools_map.values() + @utils.append_capabilities def _get_pool_stats(self, pool): return { 'pool_name': pool.name, @@ -470,8 +496,6 @@ class CommonAdapter(object): 'location_info': ('%(pool_name)s|%(array_serial)s' % {'pool_name': pool.name, 'array_serial': self.serial_number}), - 'thin_provisioning_support': True, - 'thick_provisioning_support': True, 'compression_support': pool.is_all_flash, 'max_over_subscription_ratio': ( self.max_over_subscription_ratio), @@ -643,9 +667,7 @@ class CommonAdapter(object): if src_lun is None: # If size is not specified, need to get the size from LUN # of snapshot. - lun = self.client.get_lun( - lun_id=src_snap.storage_resource.get_id()) - size_in_m = utils.byte_to_mib(lun.size_total) + size_in_m = utils.byte_to_mib(src_snap.size) else: size_in_m = utils.byte_to_mib(src_lun.size_total) vol_utils.copy_volume( @@ -814,6 +836,95 @@ class CommonAdapter(object): 'host-assisted migration.') return False, None + def create_group(self, group): + """Creates a generic group. + + :param group: group information + """ + cg_name = group.id + description = group.description if group.description else group.name + + LOG.info('Create group: %(name)s, description: %(description)s', + {'name': cg_name, 'description': description}) + + self.client.create_cg(cg_name, description=description) + return {'status': fields.GroupStatus.AVAILABLE} + + def delete_group(self, group): + """Deletes the generic group. + + :param group: the group to delete + """ + + # Deleting cg will also delete all the luns in it. + self.client.delete_cg(group.id) + return None, None + + def update_group(self, group, add_volumes, remove_volumes): + add_lun_ids = (set(map(self.get_lun_id, add_volumes)) if add_volumes + else set()) + remove_lun_ids = (set(map(self.get_lun_id, remove_volumes)) + if remove_volumes else set()) + self.client.update_cg(group.id, add_lun_ids, remove_lun_ids) + return {'status': fields.GroupStatus.AVAILABLE}, None, None + + def copy_luns_in_group(self, group, volumes, src_cg_snap, src_volumes): + # Use dd to copy data here. The reason why not using thinclone is: + # 1. Cannot use cg thinclone due to the tight couple between source + # group and cloned one. + # 2. Cannot use lun thinclone due to clone lun in cg is not supported. + + lun_snaps = self.client.filter_snaps_in_cg_snap(src_cg_snap.id) + + # Make sure the `lun_snaps` is as order of `src_volumes` + src_lun_ids = [self.get_lun_id(volume) for volume in src_volumes] + lun_snaps.sort(key=lambda snap: src_lun_ids.index(snap.lun.id)) + + dest_luns = [self._dd_copy(VolumeParams(self, dest_volume), lun_snap) + for dest_volume, lun_snap in zip(volumes, lun_snaps)] + + self.client.create_cg(group.id, lun_add=dest_luns) + return ({'status': fields.GroupStatus.AVAILABLE}, + [{'id': dest_volume.id, 'status': fields.GroupStatus.AVAILABLE} + for dest_volume in volumes]) + + def create_group_from_snap(self, group, volumes, + group_snapshot, snapshots): + src_cg_snap = self.client.get_snap(group_snapshot.id) + src_vols = ([snap.volume for snap in snapshots] if snapshots else []) + return self.copy_luns_in_group(group, volumes, src_cg_snap, src_vols) + + def create_cloned_group(self, group, volumes, source_group, source_vols): + src_group_snap_name = 'snap_clone_group_{}'.format(source_group.id) + create_snap_func = functools.partial(self.client.create_cg_snap, + source_group.id, + src_group_snap_name) + with utils.assure_cleanup(create_snap_func, + self.client.delete_snap, + True) as src_cg_snap: + LOG.debug('Internal group snapshot for clone is created, ' + 'name: %(name)s, id: %(id)s.', + {'name': src_group_snap_name, + 'id': src_cg_snap.get_id()}) + source_vols = source_vols if source_vols else [] + return self.copy_luns_in_group(group, volumes, src_cg_snap, + source_vols) + + def create_group_snapshot(self, group_snapshot, snapshots): + self.client.create_cg_snap(group_snapshot.group_id, + snap_name=group_snapshot.id) + + model_update = {'status': fields.GroupStatus.AVAILABLE} + snapshots_model_update = [{'id': snapshot.id, + 'status': fields.SnapshotStatus.AVAILABLE} + for snapshot in snapshots] + return model_update, snapshots_model_update + + def delete_group_snapshot(self, group_snapshot): + cg_snap = self.client.get_snap(group_snapshot.id) + self.client.delete_snap(cg_snap) + return None, None + class ISCSIAdapter(CommonAdapter): protocol = PROTOCOL_ISCSI diff --git a/cinder/volume/drivers/dell_emc/unity/client.py b/cinder/volume/drivers/dell_emc/unity/client.py index 31ffcb72e14..74b0204dd07 100644 --- a/cinder/volume/drivers/dell_emc/unity/client.py +++ b/cinder/volume/drivers/dell_emc/unity/client.py @@ -58,7 +58,7 @@ class UnityClient(object): def create_lun(self, name, size, pool, description=None, io_limit_policy=None, is_thin=None, - is_compressed=None): + is_compressed=None, cg_name=None): """Creates LUN on the Unity system. :param name: lun name @@ -68,6 +68,7 @@ class UnityClient(object): :param io_limit_policy: io limit on the LUN :param is_thin: if False, a thick LUN will be created :param is_compressed: is compressed LUN enabled + :param cg_name: the name of cg to join if any :return: UnityLun object """ try: @@ -312,9 +313,9 @@ class UnityClient(object): # so use filter instead of shadow_copy here. wwns.update(p.wwn.upper() for p in filter( - lambda fcp: (allowed_ports is None or - fcp.get_id() in allowed_ports), - paths.fc_port)) + lambda fcp: (allowed_ports is None or + fcp.get_id() in allowed_ports), + paths.fc_port)) else: ports = self.get_fc_ports() ports = ports.shadow_copy(port_ids=allowed_ports) @@ -349,3 +350,41 @@ class UnityClient(object): def restore_snapshot(self, snap_name): snap = self.get_snap(snap_name) return snap.restore(delete_backup=True) + + def create_cg(self, name, description=None, lun_add=None): + try: + cg = self.system.create_cg(name, description=description, + lun_add=lun_add) + except storops_ex.UnityConsistencyGroupNameInUseError: + LOG.debug('CG %s already exists. Return the existing one.', name) + cg = self.system.get_cg(name=name) + return cg + + def get_cg(self, name): + try: + cg = self.system.get_cg(name=name) + except storops_ex.UnityResourceNotFoundError: + LOG.info('CG %s not found.', name) + return None + else: + return cg + + def delete_cg(self, name): + cg = self.get_cg(name) + if cg: + cg.delete() # Deleting cg will also delete the luns in it + + def update_cg(self, name, add_lun_ids, remove_lun_ids): + cg = self.get_cg(name) + cg.update_lun(add_luns=[self.get_lun(lun_id=lun_id) + for lun_id in add_lun_ids], + remove_luns=[self.get_lun(lun_id=lun_id) + for lun_id in remove_lun_ids]) + + def create_cg_snap(self, cg_name, snap_name=None): + cg = self.get_cg(cg_name) + # Creating snap of cg will create corresponding snaps of luns in it + return cg.create_snap(name=snap_name, is_auto_delete=False) + + def filter_snaps_in_cg_snap(self, cg_snap_id): + return self.system.get_snap(snap_group=cg_snap_id).list diff --git a/cinder/volume/drivers/dell_emc/unity/driver.py b/cinder/volume/drivers/dell_emc/unity/driver.py index aed0d161b2d..096f1ab0dd3 100644 --- a/cinder/volume/drivers/dell_emc/unity/driver.py +++ b/cinder/volume/drivers/dell_emc/unity/driver.py @@ -18,11 +18,14 @@ from oslo_config import cfg from oslo_log import log as logging +import six + from cinder import interface from cinder.volume import configuration from cinder.volume import driver from cinder.volume.drivers.dell_emc.unity import adapter from cinder.volume.drivers.san.san import san_opts +from cinder.volume import utils from cinder.zonemanager import utils as zm_utils LOG = logging.getLogger(__name__) @@ -33,7 +36,7 @@ UNITY_OPTS = [ cfg.ListOpt('unity_storage_pool_names', default=[], help='A comma-separated list of storage pool names to be ' - 'used.'), + 'used.'), cfg.ListOpt('unity_io_ports', default=[], help='A comma-separated list of iSCSI or FC ports to be used. ' @@ -46,6 +49,20 @@ UNITY_OPTS = [ CONF.register_opts(UNITY_OPTS, group=configuration.SHARED_CONF_GROUP) +def skip_if_not_cg(func): + @six.wraps(func) + def inner(self, *args, **kwargs): + # Only used to decorating the second argument is `group` + if utils.is_group_a_cg_snapshot_type(args[1]): + return func(self, *args, **kwargs) + + LOG.debug('Group is not a consistency group. Unity driver does ' + 'nothing.') + # This exception will let cinder handle it as a generic group + raise NotImplementedError() + return inner + + @interface.volumedriver class UnityDriver(driver.ManageableVD, driver.ManageableSnapshotsVD, @@ -60,9 +77,10 @@ class UnityDriver(driver.ManageableVD, 4.0.0 - Support remove empty host 4.2.0 - Support compressed volume 5.0.0 - Support storage assisted volume migration + 6.0.0 - Support generic group and consistent group """ - VERSION = '05.00.00' + VERSION = '06.00.00' VENDOR = 'Dell EMC' # ThirdPartySystems wiki page CI_WIKI_NAME = "EMC_UNITY_CI" @@ -252,3 +270,43 @@ class UnityDriver(driver.ManageableVD, def revert_to_snapshot(self, context, volume, snapshot): """Reverts a volume to a snapshot.""" return self.adapter.restore_snapshot(volume, snapshot) + + @skip_if_not_cg + def create_group(self, context, group): + """Creates a consistency group.""" + return self.adapter.create_group(group) + + @skip_if_not_cg + def delete_group(self, context, group, volumes): + """Deletes a consistency group.""" + return self.adapter.delete_group(group) + + @skip_if_not_cg + def update_group(self, context, group, add_volumes=None, + remove_volumes=None): + """Updates a consistency group, i.e. add/remove luns to/from it.""" + # TODO(Ryan L) update other information (like description) of group + return self.adapter.update_group(group, add_volumes, remove_volumes) + + @skip_if_not_cg + def create_group_from_src(self, context, group, volumes, + group_snapshot=None, snapshots=None, + source_group=None, source_vols=None): + """Creates a consistency group from another group or group snapshot.""" + if group_snapshot: + return self.adapter.create_group_from_snap(group, volumes, + group_snapshot, + snapshots) + elif source_group: + return self.adapter.create_cloned_group(group, volumes, + source_group, source_vols) + + @skip_if_not_cg + def create_group_snapshot(self, context, group_snapshot, snapshots): + """Creates a snapshot of consistency group.""" + return self.adapter.create_group_snapshot(group_snapshot, snapshots) + + @skip_if_not_cg + def delete_group_snapshot(self, context, group_snapshot, snapshots): + """Deletes a snapshot of consistency group.""" + return self.adapter.delete_group_snapshot(group_snapshot) diff --git a/cinder/volume/drivers/dell_emc/unity/utils.py b/cinder/volume/drivers/dell_emc/unity/utils.py index e289a0b0ee2..d931924a0d1 100644 --- a/cinder/volume/drivers/dell_emc/unity/utils.py +++ b/cinder/volume/drivers/dell_emc/unity/utils.py @@ -319,6 +319,22 @@ def lock_if(condition, lock_name): return functools.partial +def append_capabilities(func): + capabilities = { + 'thin_provisioning_support': True, + 'thick_provisioning_support': True, + 'consistent_group_snapshot_enabled': True + } + + @six.wraps(func) + def _inner(*args, **kwargs): + output = func(*args, **kwargs) + output.update(capabilities) + return output + + return _inner + + def is_multiattach_to_host(volume_attachment, host_name): # When multiattach is enabled, a volume could be attached to two or more # instances which are hosted on one nova host. diff --git a/doc/source/configuration/block-storage/drivers/dell-emc-unity-driver.rst b/doc/source/configuration/block-storage/drivers/dell-emc-unity-driver.rst index be1f51d5e59..895dd9e0b14 100644 --- a/doc/source/configuration/block-storage/drivers/dell-emc-unity-driver.rst +++ b/doc/source/configuration/block-storage/drivers/dell-emc-unity-driver.rst @@ -35,6 +35,11 @@ Supported operations - Efficient non-disruptive volume backup. - Revert a volume to a snapshot. - Create thick volumes. +- Create and delete consistent groups. +- Add/remove volumes to/from a consistent group. +- Create and delete consistent group snapshots. +- Clone a consistent group. +- Create a consistent group from a snapshot. - Attach a volume to multiple servers simultaneously (multiattach). Driver configuration @@ -382,6 +387,26 @@ attached hosts. For more detail, please refer to https://developer.openstack.org/api-ref/block-storage/v2/?expanded=force-detach-volume-detail#force-detach-volume +Consistent group support +~~~~~~~~~~~~~~~~~~~~~~~~ + +For a group to support consistent group snapshot, the group specs in the +corresponding group type should have the following entry: + +.. code-block:: ini + + {'consistent_group_snapshot_enabled': True} + +Similarly, for a volume to be in a group that supports consistent group +snapshots, the volume type extra specs would also have the following entry: + +.. code-block:: ini + + {'consistent_group_snapshot_enabled': True} + +Refer to https://docs.openstack.org/cinder/latest/admin/blockstorage-groups.html +for command lines detail. + Troubleshooting ~~~~~~~~~~~~~~~ diff --git a/doc/source/reference/support-matrix.ini b/doc/source/reference/support-matrix.ini index 894956b575a..384008f2d30 100644 --- a/doc/source/reference/support-matrix.ini +++ b/doc/source/reference/support-matrix.ini @@ -554,7 +554,7 @@ driver.datera=missing driver.dell_emc_powermax=complete driver.dell_emc_ps=missing driver.dell_emc_sc=complete -driver.dell_emc_unity=missing +driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete driver.dell_emc_vmax_3=complete driver.dell_emc_vnx=complete diff --git a/releasenotes/notes/support-cg-2b55da0bd9f69c7d.yaml b/releasenotes/notes/support-cg-2b55da0bd9f69c7d.yaml new file mode 100644 index 00000000000..27e6ca0fa72 --- /dev/null +++ b/releasenotes/notes/support-cg-2b55da0bd9f69c7d.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Dell EMC Unity driver: Add consistent group support. Users could create a + group type supporting consistent groups with specification + `'consistent_group_snapshot_enabled': True`, then any groups created + of that group type are consistent groups, otherwise they are generic + groups. The supported operations are: create/delete consistent groups, add + volumes to and remove volumes from consistent groups, create/delete + consistent group snapshots, create consistent groups from snapshots, clone + consistent groups.