diff --git a/cinder/tests/unit/volume/drivers/dell_emc/vmax/test_vmax.py b/cinder/tests/unit/volume/drivers/dell_emc/vmax/test_vmax.py index 5206e5d6dc4..05459122664 100644 --- a/cinder/tests/unit/volume/drivers/dell_emc/vmax/test_vmax.py +++ b/cinder/tests/unit/volume/drivers/dell_emc/vmax/test_vmax.py @@ -68,6 +68,7 @@ class VMAXCommonData(object): storagegroup_name_f = 'OS-HostX-SRP_1-DiamondDSS-OS-fibre-PG' storagegroup_name_i = 'OS-HostX-SRP_1-Diamond-DSS-OS-iscsi-PG' defaultstoragegroup_name = 'OS-SRP_1-Diamond-DSS-SG' + storagegroup_list = [defaultstoragegroup_name] default_sg_no_slo = 'OS-no_SLO-SG' default_sg_compr_disabled = 'OS-SRP_1-Diamond-DSS-CD-SG' default_sg_re_enabled = 'OS-SRP_1-Diamond-DSS-RE-SG' @@ -88,15 +89,16 @@ class VMAXCommonData(object): group_snapshot_name = 'Grp_snapshot' target_group_name = 'Grp_target' storagegroup_name_with_id = 'GrpId_group_name' - rdf_managed_async_grp = "OS-%s-async-rdf-sg" % rdf_group_name + rdf_managed_async_grp = "OS-%s-Asynchronous-rdf-sg" % rdf_group_name # connector info wwpn1 = "123456789012345" wwpn2 = "123456789054321" wwnn1 = "223456789012345" initiator = 'iqn.1993-08.org.debian: 01: 222' - ip = u'123.456.7.8' + ip, ip2 = u'123.456.7.8', u'123.456.7.9' iqn = u'iqn.1992-04.com.emc:600009700bca30c01e3e012e00000001,t,0x0001' + iqn2 = u'iqn.1992-04.com.emc:600009700bca30c01e3e012e00000002,t,0x0001' connector = {'ip': ip, 'initiator': initiator, 'wwpns': [wwpn1, wwpn2], @@ -113,6 +115,10 @@ class VMAXCommonData(object): 'initiator_group': initiatorgroup_name_f, 'port_group': port_group_name_f, 'target_wwns': target_wwns} + zoning_mappings_metro = deepcopy(zoning_mappings) + zoning_mappings_metro.update({'metro_port_group': port_group_name_f, + 'metro_ig': initiatorgroup_name_f, + 'metro_array': remote_array}) device_map = {} for wwn in connector['wwpns']: @@ -131,6 +137,10 @@ class VMAXCommonData(object): 'array': array, 'controller': {'host': '10.00.00.00'}, 'hostlunid': 3} + iscsi_device_info_metro = deepcopy(iscsi_device_info) + iscsi_device_info_metro['metro_ip_and_iqn'] = [{'ip': ip2, 'iqn': iqn2}] + iscsi_device_info_metro['metro_hostlunid'] = 2 + fc_device_info = {'maskingview': masking_view_name_f, 'array': array, 'controller': {'host': '10.00.00.00'}, @@ -246,7 +256,8 @@ class VMAXCommonData(object): rep_extra_specs['retries'] = 0 rep_extra_specs['srp'] = srp2 rep_extra_specs['rep_mode'] = 'Synchronous' - + rep_extra_specs2 = deepcopy(rep_extra_specs) + rep_extra_specs2[utils.PORTGROUPNAME] = port_group_name_f test_volume_type_1 = volume_type.VolumeType( id='2b06255d-f5f0-4520-a953-b029196add6a', name='abc', extra_specs=extra_specs @@ -1285,6 +1296,19 @@ class VMAXUtilsTest(test.TestCase): rep_device_list5[0]['mode'] = 'async' rep_config5 = self.utils.get_replication_config(rep_device_list5) self.assertEqual(utils.REP_ASYNC, rep_config5['mode']) + # Success, mode is metro - no other options set + rep_device_list6 = rep_device_list5 + rep_device_list6[0]['mode'] = 'metro' + rep_config6 = self.utils.get_replication_config(rep_device_list6) + self.assertFalse(rep_config6['metro_bias']) + self.assertFalse(rep_config6['allow_delete_metro']) + # Success, mode is metro - metro options true + rep_device_list7 = rep_device_list6 + rep_device_list6[0].update( + {'allow_delete_metro': 'true', 'metro_use_bias': 'true'}) + rep_config7 = self.utils.get_replication_config(rep_device_list7) + self.assertTrue(rep_config7['metro_bias']) + self.assertTrue(rep_config7['allow_delete_metro']) def test_is_volume_failed_over(self): vol = deepcopy(self.data.test_volume) @@ -1407,12 +1431,33 @@ class VMAXUtilsTest(test.TestCase): self.assertEqual('-RA', async_prefix) sync_prefix = self.utils.get_replication_prefix(utils.REP_SYNC) self.assertEqual('-RE', sync_prefix) + metro_prefix = self.utils.get_replication_prefix(utils.REP_METRO) + self.assertEqual('-RM', metro_prefix) def test_get_async_rdf_managed_grp_name(self): - rep_config = {'rdf_group_label': self.data.rdf_group_name} + rep_config = {'rdf_group_label': self.data.rdf_group_name, + 'mode': utils.REP_ASYNC} grp_name = self.utils.get_async_rdf_managed_grp_name(rep_config) self.assertEqual(self.data.rdf_managed_async_grp, grp_name) + def test_is_metro_device(self): + rep_config = {'mode': utils.REP_METRO} + is_metro = self.utils.is_metro_device( + rep_config, self.data.rep_extra_specs) + self.assertTrue(is_metro) + rep_config2 = {'mode': utils.REP_ASYNC} + is_metro2 = self.utils.is_metro_device( + rep_config2, self.data.rep_extra_specs) + self.assertFalse(is_metro2) + + def test_does_vol_need_rdf_management_group(self): + self.assertFalse(self.utils.does_vol_need_rdf_management_group( + self.data.rep_extra_specs)) + extra_specs = deepcopy(self.data.rep_extra_specs) + extra_specs[utils.REP_MODE] = utils.REP_ASYNC + self.assertTrue(self.utils.does_vol_need_rdf_management_group( + extra_specs)) + class VMAXRestTest(test.TestCase): def setUp(self): @@ -1626,18 +1671,6 @@ class VMAXRestTest(test.TestCase): self.data.array, self.data.defaultstoragegroup_name) self.assertEqual(ref_details, sg_details) - def test_get_storage_group_list(self): - ref_details = self.data.sg_list['storageGroupId'] - sg_list = self.rest.get_storage_group_list( - self.data.array, {}) - self.assertEqual(ref_details, sg_list) - - def test_get_storage_group_list_none(self): - with mock.patch.object(self.rest, 'get_resource', return_value=None): - sg_list = self.rest.get_storage_group_list( - self.data.array, {}) - self.assertEqual([], sg_list) - def test_create_storage_group(self): with mock.patch.object(self.rest, 'create_resource'): payload = {'someKey': 'someValue'} @@ -2575,10 +2608,11 @@ class VMAXRestTest(test.TestCase): def test_create_rdf_device_pair(self): ref_dict = {'array': self.data.remote_array, 'device_id': self.data.device_id2} + extra_specs = deepcopy(self.data.extra_specs) + extra_specs[utils.REP_MODE] = utils.REP_SYNC rdf_dict = self.rest.create_rdf_device_pair( self.data.array, self.data.device_id, self.data.rdf_group_no, - self.data.device_id2, self.data.remote_array, - self.data.extra_specs) + self.data.device_id2, self.data.remote_array, extra_specs) self.assertEqual(ref_dict, rdf_dict) def test_create_rdf_device_pair_async(self): @@ -2591,6 +2625,35 @@ class VMAXRestTest(test.TestCase): self.data.device_id2, self.data.remote_array, extra_specs) self.assertEqual(ref_dict, rdf_dict) + def test_create_rdf_device_pair_metro(self): + ref_dict = {'array': self.data.remote_array, + 'device_id': self.data.device_id2} + extra_specs = deepcopy(self.data.extra_specs) + extra_specs[utils.REP_MODE] = utils.REP_METRO + extra_specs[utils.METROBIAS] = True + rdf_dict = self.rest.create_rdf_device_pair( + self.data.array, self.data.device_id, self.data.rdf_group_no, + self.data.device_id2, self.data.remote_array, extra_specs) + self.assertEqual(ref_dict, rdf_dict) + + @mock.patch.object(rest.VMAXRest, 'get_rdf_group', + side_effect=[{'numDevices': 0}, {'numDevices': 0}, + {'numDevices': 1}]) + def test_get_metro_payload_info(self, mock_rdfg): + ref_payload = {"establish": 'true', "rdfType": 'RDF1'} + payload1 = self.rest.get_metro_payload_info( + self.data.array, ref_payload, self.data.rdf_group_no, {}) + self.assertEqual(ref_payload, payload1) + payload2 = self.rest.get_metro_payload_info( + self.data.array, ref_payload, self.data.rdf_group_no, + {'metro_bias': True}) + self.assertEqual('true', payload2['metroBias']) + ref_payload2 = {"establish": 'true', "rdfType": 'RDF1'} + payload3 = self.rest.get_metro_payload_info( + self.data.array, ref_payload2, self.data.rdf_group_no, {}) + ref_payload3 = {"rdfType": 'NA', "format": 'true'} + self.assertEqual(ref_payload3, payload3) + def test_modify_rdf_device_pair(self): resource_name = "70/volume/00001" common_opts = {"force": 'false', @@ -2598,11 +2661,9 @@ class VMAXRestTest(test.TestCase): "star": 'false', "hop2": 'false', "bypass": 'false'} - split_opts = deepcopy(common_opts) - split_opts.update({"immediate": 'false'}) - split_payload = {"action": "Split", - 'executionOption': 'ASYNCHRONOUS', - "split": split_opts} + suspend_payload = {"action": "Suspend", + 'executionOption': 'ASYNCHRONOUS', + "suspend": common_opts} failover_opts = deepcopy(common_opts) failover_opts.update({"establish": 'true', @@ -2617,20 +2678,26 @@ class VMAXRestTest(test.TestCase): return_value=(200, self.data.job_list[0])) as mock_mod: self.rest.modify_rdf_device_pair( self.data.array, self.data.device_id, self.data.rdf_group_no, - self.data.extra_specs, split=True) + self.data.extra_specs, suspend=True) mock_mod.assert_called_once_with( self.data.array, 'replication', 'rdf_group', - split_payload, resource_name=resource_name, + suspend_payload, resource_name=resource_name, private='/private') mock_mod.reset_mock() self.rest.modify_rdf_device_pair( self.data.array, self.data.device_id, self.data.rdf_group_no, - self.data.extra_specs, split=False) + self.data.extra_specs, suspend=False) mock_mod.assert_called_once_with( self.data.array, 'replication', 'rdf_group', failover_payload, resource_name=resource_name, private='/private') + @mock.patch.object(rest.VMAXRest, 'delete_resource') + def test_delete_rdf_pair(self, mock_del): + self.rest.delete_rdf_pair( + self.data.array, self.data.device_id, self.data.rdf_group_no) + mock_del.assert_called_once() + def test_get_storage_group_rep(self): array = self.data.array source_group_name = self.data.storagegroup_name_source @@ -2950,24 +3017,36 @@ class VMAXProvisionTest(test.TestCase): self.data.array, 'no_workload_sg')) self.assertEqual(ref_settings2, sg_slo_settings2) - def test_break_rdf_relationship(self): + @mock.patch.object(rest.VMAXRest, 'wait_for_rdf_consistent_state') + @mock.patch.object(rest.VMAXRest, 'delete_rdf_pair') + @mock.patch.object(rest.VMAXRest, 'modify_rdf_device_pair') + def test_break_rdf_relationship(self, mock_mod, mock_del, mock_wait): array = self.data.array device_id = self.data.device_id target_device = self.data.device_id2 rdf_group_name = self.data.rdf_group_name rep_extra_specs = self.data.rep_extra_specs - with mock.patch.object( - self.provision.rest, 'modify_rdf_device_pair') as mod_rdf: - with mock.patch.object( - self.provision.rest, 'delete_rdf_pair') as del_rdf: - self.provision.break_rdf_relationship( - array, device_id, target_device, - rdf_group_name, rep_extra_specs, "Synchronized") - mod_rdf.assert_called_once_with( - array, device_id, rdf_group_name, rep_extra_specs, - split=True) - del_rdf.assert_called_once_with( - array, device_id, rdf_group_name) + self.provision.break_rdf_relationship( + array, device_id, target_device, + rdf_group_name, rep_extra_specs, "Synchronized") + mock_mod.assert_called_once_with( + array, device_id, rdf_group_name, rep_extra_specs, + suspend=True) + mock_del.assert_called_once_with( + array, device_id, rdf_group_name) + # sync still in progress + self.provision.break_rdf_relationship( + array, device_id, target_device, + rdf_group_name, rep_extra_specs, "SyncInProg") + mock_wait.assert_called_once() + + @mock.patch.object(provision.VMAXProvision, 'disable_group_replication') + @mock.patch.object(provision.VMAXProvision, 'delete_rdf_pair') + def test_break_metro_rdf_pair(self, mock_del, mock_disable): + self.provision.break_metro_rdf_pair( + self.data.array, self.data.device_id, self.data.device_id2, + self.data.rdf_group_no, self.data.rep_extra_specs, 'metro_grp') + mock_del.assert_called_once() def test_delete_rdf_pair_async(self): with mock.patch.object( @@ -2976,7 +3055,7 @@ class VMAXProvisionTest(test.TestCase): extra_specs[utils.REP_MODE] = utils.REP_ASYNC self.provision.delete_rdf_pair( self.data.array, self.data.device_id, - self.data.rdf_group_no, extra_specs) + self.data.rdf_group_no, self.data.device_id2, extra_specs) mock_del_rdf.assert_called_once() def test_failover_volume(self): @@ -2990,15 +3069,13 @@ class VMAXProvisionTest(test.TestCase): array, device_id, rdf_group_name, extra_specs, '', True) mod_rdf.assert_called_once_with( - array, device_id, rdf_group_name, extra_specs, - split=False) + array, device_id, rdf_group_name, extra_specs) mod_rdf.reset_mock() self.provision.failover_volume( array, device_id, rdf_group_name, extra_specs, '', False) mod_rdf.assert_called_once_with( - array, device_id, rdf_group_name, extra_specs, - split=False) + array, device_id, rdf_group_name, extra_specs) def test_create_volume_group_success(self): array = self.data.array @@ -3341,28 +3418,26 @@ class VMAXCommonTest(test.TestCase): volume, connector, extra_specs, masking_view_dict) self.assertEqual(ref_dict, device_info_dict) - def test_attach_volume_failed(self): + @mock.patch.object(masking.VMAXMasking, + 'check_if_rollback_action_for_masking_required') + @mock.patch.object(masking.VMAXMasking, 'setup_masking_view', + return_value={}) + @mock.patch.object(common.VMAXCommon, 'find_host_lun_id', + return_value=({}, False, [])) + def test_attach_volume_failed(self, mock_lun, mock_setup, mock_rollback): volume = self.data.test_volume connector = self.data.connector extra_specs = deepcopy(self.data.extra_specs) extra_specs[utils.PORTGROUPNAME] = self.data.port_group_name_f masking_view_dict = self.common._populate_masking_dict( volume, connector, extra_specs) - with mock.patch.object(self.masking, 'setup_masking_view', - return_value={}): - with mock.patch.object(self.common, 'find_host_lun_id', - return_value=({}, False, [])): - with mock.patch.object( - self.masking, - 'check_if_rollback_action_for_masking_required'): - self.assertRaises(exception.VolumeBackendAPIException, - self.common._attach_volume, volume, - connector, extra_specs, - masking_view_dict) - device_id = self.data.device_id - (self.masking. - check_if_rollback_action_for_masking_required. - assert_called_once_with(self.data.array, device_id, {})) + self.assertRaises(exception.VolumeBackendAPIException, + self.common._attach_volume, volume, + connector, extra_specs, + masking_view_dict) + device_id = self.data.device_id + (mock_rollback.assert_called_once_with( + self.data.array, volume, device_id, {})) def test_terminate_connection(self): volume = self.data.test_volume @@ -3490,21 +3565,35 @@ class VMAXCommonTest(test.TestCase): volume, host, extra_specs) self.assertEqual({}, maskedvols) + @mock.patch.object(common.VMAXCommon, 'get_remote_target_device', + return_value=VMAXCommonData.device_id2) + def test_find_host_lun_id_rep_extra_specs(self, mock_tgt): + self.common.find_host_lun_id( + self.data.test_volume, 'HostX', + self.data.extra_specs, self.data.rep_extra_specs) + mock_tgt.assert_called_once() + def test_get_masking_views_from_volume(self): array = self.data.array device_id = self.data.device_id host = 'HostX' ref_mv_list = [self.data.masking_view_name_f] - maskingview_list = self.common.get_masking_views_from_volume( - array, device_id, host) + maskingview_list, __ = self.common.get_masking_views_from_volume( + array, self.data.test_volume, device_id, host) self.assertEqual(ref_mv_list, maskingview_list) + # is metro + with mock.patch.object(self.utils, 'is_metro_device', + return_value=True): + __, is_metro = self.common.get_masking_views_from_volume( + array, self.data.test_volume, device_id, host) + self.assertTrue(is_metro) def test_get_masking_views_from_volume_wrong_host(self): array = self.data.array device_id = self.data.device_id host = 'DifferentHost' - maskingview_list = self.common.get_masking_views_from_volume( - array, device_id, host) + maskingview_list, __ = self.common.get_masking_views_from_volume( + array, self.data.test_volume, device_id, host) self.assertEqual([], maskingview_list) def test_find_host_lun_id_no_host_check(self): @@ -3546,7 +3635,9 @@ class VMAXCommonTest(test.TestCase): self.assertRaises(exception.VolumeBackendAPIException, self.common._initial_setup, volume) - def test_populate_masking_dict(self): + @mock.patch.object(common.VMAXCommon, 'get_remote_target_device', + return_value=VMAXCommonData.device_id2) + def test_populate_masking_dict(self, mock_tgt): volume = self.data.test_volume connector = self.data.connector extra_specs = deepcopy(self.data.extra_specs) @@ -3555,6 +3646,18 @@ class VMAXCommonTest(test.TestCase): masking_view_dict = self.common._populate_masking_dict( volume, connector, extra_specs) self.assertEqual(ref_mv_dict, masking_view_dict) + # Metro volume, pass in rep_extra_specs and retrieve target device + rep_extra_specs = deepcopy(self.data.rep_extra_specs) + rep_extra_specs[utils.PORTGROUPNAME] = self.data.port_group_name_f + self.common._populate_masking_dict( + volume, connector, extra_specs, rep_extra_specs) + mock_tgt.assert_called_once() + # device_id is None + with mock.patch.object(self.common, '_find_device_on_array', + return_value=None): + self.assertRaises(exception.VolumeBackendAPIException, + self.common._populate_masking_dict, + volume, connector, extra_specs) def test_populate_masking_dict_no_slo(self): volume = self.data.test_volume @@ -3838,27 +3941,41 @@ class VMAXCommonTest(test.TestCase): mock_clean.assert_called_once_with( volume, volume_name, device_id, extra_specs) + @mock.patch.object(utils.VMAXUtils, 'is_volume_failed_over', + side_effect=[True, False]) @mock.patch.object(common.VMAXCommon, '_get_replication_extra_specs', return_value=VMAXCommonData.rep_extra_specs) - def test_get_target_wwns_from_masking_view(self, mock_rep_specs): - target_wwns = self.common.get_target_wwns_from_masking_view( - self.data.test_volume, self.data.connector) + def test_get_target_wwns_from_masking_view(self, mock_rep_specs, mock_fo): ref_wwns = [self.data.wwnn1] - self.assertEqual(ref_wwns, target_wwns) - # Volume is failed over - with mock.patch.object(self.utils, 'is_volume_failed_over', - return_value=True): - self.common.get_target_wwns_from_masking_view( - self.data.test_volume, self.data.connector) - mock_rep_specs.assert_called_once() + for x in range(0, 2): + target_wwns = self.common._get_target_wwns_from_masking_view( + self.data.device_id, self.data.connector['host'], + self.data.extra_specs) + self.assertEqual(ref_wwns, target_wwns) def test_get_target_wwns_from_masking_view_no_mv(self): - with mock.patch.object(self.common, 'get_masking_views_from_volume', + with mock.patch.object(self.common, '_get_masking_views_from_volume', return_value=None): - target_wwns = self.common.get_target_wwns_from_masking_view( - self.data.test_volume, self.data.connector) + target_wwns = self.common._get_target_wwns_from_masking_view( + self.data.device_id, self.data.connector['host'], + self.data.extra_specs) self.assertEqual([], target_wwns) + @mock.patch.object(common.VMAXCommon, '_get_replication_extra_specs', + return_value=VMAXCommonData.rep_extra_specs) + @mock.patch.object(common.VMAXCommon, 'get_remote_target_device', + return_value=(VMAXCommonData.device_id2,)) + @mock.patch.object(utils.VMAXUtils, 'is_metro_device', + side_effect=[False, True]) + def test_get_target_wwns(self, mock_metro, mock_tgt, mock_specs): + __, metro_wwns = self.common.get_target_wwns_from_masking_view( + self.data.test_volume, self.data.connector) + self.assertEqual([], metro_wwns) + # Is metro volume + __, metro_wwns = self.common.get_target_wwns_from_masking_view( + self.data.test_volume, self.data.connector) + self.assertEqual([self.data.wwnn1], metro_wwns) + def test_get_port_group_from_masking_view(self): array = self.data.array maskingview_name = self.data.masking_view_name_f @@ -4724,11 +4841,20 @@ class VMAXFCTest(test.TestCase): def test_get_zoning_mappings_no_mv(self): with mock.patch.object(self.common, 'get_masking_views_from_volume', - return_value=None): + return_value=(None, False)): zoning_mappings = self.driver._get_zoning_mappings( self.data.test_volume, self.data.connector) self.assertEqual({}, zoning_mappings) + @mock.patch.object( + common.VMAXCommon, 'get_masking_views_from_volume', + return_value=([VMAXCommonData.masking_view_name_f], True)) + def test_get_zoning_mappings_metro(self, mock_mv): + ref_mappings = self.data.zoning_mappings_metro + zoning_mappings = self.driver._get_zoning_mappings( + self.data.test_volume, self.data.connector) + self.assertEqual(ref_mappings, zoning_mappings) + def test_cleanup_zones_other_vols_mapped(self): ref_data = {'driver_volume_type': 'fibre_channel', 'data': {}} @@ -4754,7 +4880,7 @@ class VMAXFCTest(test.TestCase): driver = fc.VMAXFCDriver(configuration=self.configuration) with mock.patch.object(driver.common, 'get_target_wwns_from_masking_view', - return_value=self.data.target_wwns): + return_value=(self.data.target_wwns, [])): targets, target_map = driver._build_initiator_target_map( self.data.test_volume, self.data.connector) self.assertEqual(ref_target_map, target_map) @@ -4946,7 +5072,7 @@ class VMAXISCSITest(test.TestCase): data = self.driver.get_iscsi_dict(device_info, volume) self.assertEqual(ref_data, data) self.driver.vmax_get_iscsi_properties.assert_called_once_with( - volume, ip_and_iqn, True, host_lun_id) + volume, ip_and_iqn, True, host_lun_id, None, None) def test_get_iscsi_dict_exception(self): device_info = {'ip_and_iqn': ''} @@ -4954,6 +5080,22 @@ class VMAXISCSITest(test.TestCase): self.driver.get_iscsi_dict, device_info, self.data.test_volume) + def test_get_iscsi_dict_metro(self): + ip_and_iqn = self.common._find_ip_and_iqns( + self.data.array, self.data.port_group_name_i) + host_lun_id = self.data.iscsi_device_info_metro['hostlunid'] + volume = self.data.test_volume + device_info = self.data.iscsi_device_info_metro + ref_data = {'driver_volume_type': 'iscsi', 'data': {}} + with mock.patch.object( + self.driver, 'vmax_get_iscsi_properties', return_value={}): + data = self.driver.get_iscsi_dict(device_info, volume) + self.assertEqual(ref_data, data) + self.driver.vmax_get_iscsi_properties.assert_called_once_with( + volume, ip_and_iqn, True, host_lun_id, + self.data.iscsi_device_info_metro['metro_ip_and_iqn'], + self.data.iscsi_device_info_metro['metro_hostlunid']) + def test_vmax_get_iscsi_properties_one_target_no_auth(self): vol = deepcopy(self.data.test_volume) ip_and_iqn = self.common._find_ip_and_iqns( @@ -4966,7 +5108,7 @@ class VMAXISCSITest(test.TestCase): 'target_lun': host_lun_id, 'volume_id': self.data.test_volume.id} iscsi_properties = self.driver.vmax_get_iscsi_properties( - vol, ip_and_iqn, True, host_lun_id) + vol, ip_and_iqn, True, host_lun_id, [], None) self.assertEqual(type(ref_properties), type(iscsi_properties)) self.assertEqual(ref_properties, iscsi_properties) @@ -4986,7 +5128,7 @@ class VMAXISCSITest(test.TestCase): 'target_lun': host_lun_id, 'volume_id': self.data.test_volume.id} iscsi_properties = self.driver.vmax_get_iscsi_properties( - self.data.test_volume, ip_and_iqn, True, host_lun_id) + self.data.test_volume, ip_and_iqn, True, host_lun_id, [], None) self.assertEqual(ref_properties, iscsi_properties) def test_vmax_get_iscsi_properties_auth(self): @@ -5017,10 +5159,33 @@ class VMAXISCSITest(test.TestCase): 'auth_username': 'auth_username', 'auth_password': 'auth_secret'} iscsi_properties = self.driver.vmax_get_iscsi_properties( - vol, ip_and_iqn, True, host_lun_id) + vol, ip_and_iqn, True, host_lun_id, None, None) self.assertEqual(ref_properties, iscsi_properties) self.driver.configuration = backup_conf + def test_vmax_get_iscsi_properties_metro(self): + ip_and_iqn = [{'ip': self.data.ip, 'iqn': self.data.iqn}] + total_ip_list = [{'ip': self.data.ip, 'iqn': self.data.iqn}, + {'ip': self.data.ip2, 'iqn': self.data.iqn2}] + host_lun_id = self.data.iscsi_device_info['hostlunid'] + host_lun_id2 = self.data.iscsi_device_info_metro['metro_hostlunid'] + ref_properties = { + 'target_portals': ( + [t['ip'] + ":3260" for t in total_ip_list]), + 'target_iqns': ( + [t['iqn'].split(",")[0] for t in total_ip_list]), + 'target_luns': [host_lun_id, host_lun_id2], + 'target_discovered': True, + 'target_iqn': ip_and_iqn[0]['iqn'].split(",")[0], + 'target_portal': ip_and_iqn[0]['ip'] + ":3260", + 'target_lun': host_lun_id, + 'volume_id': self.data.test_volume.id} + iscsi_properties = self.driver.vmax_get_iscsi_properties( + self.data.test_volume, ip_and_iqn, True, host_lun_id, + self.data.iscsi_device_info_metro['metro_ip_and_iqn'], + self.data.iscsi_device_info_metro['metro_hostlunid']) + self.assertEqual(ref_properties, iscsi_properties) + def test_terminate_connection(self): with mock.patch.object(self.common, 'terminate_connection'): self.driver.terminate_connection(self.data.test_volume, @@ -5137,6 +5302,8 @@ class VMAXMaskingTest(test.TestCase): configuration.config_group = 'MaskingTests' self._gather_info = common.VMAXCommon._gather_info common.VMAXCommon._gather_info = mock.Mock() + rest.VMAXRest._establish_rest_session = mock.Mock( + return_value=FakeRequestsSession()) driver = common.VMAXCommon( 'iSCSI', self.data.version, configuration=configuration) driver_fc = common.VMAXCommon( @@ -5180,7 +5347,8 @@ class VMAXMaskingTest(test.TestCase): @mock.patch.object( rest.VMAXRest, 'get_element_from_masking_view', - side_effect=[VMAXCommonData.port_group_name_i, Exception]) + side_effect=[VMAXCommonData.port_group_name_i, + Exception('Exception')]) def test_get_or_create_masking_view_and_map_lun( self, mock_masking_view_element, mock_masking, mock_move, mock_add_volume): @@ -5560,8 +5728,8 @@ class VMAXMaskingTest(test.TestCase): 'get_storage_groups_from_volume', side_effect=[ exception.VolumeBackendAPIException, - self.data.defaultstoragegroup_name, - self.data.defaultstoragegroup_name, None, + self.data.storagegroup_list, + self.data.storagegroup_list, None, None, ]): self.assertRaises( exception.VolumeBackendAPIException, @@ -6064,6 +6232,13 @@ class VMAXCommonReplicationTest(test.TestCase): rest.VMAXRest._establish_rest_session = mock.Mock( return_value=FakeRequestsSession()) driver = fc.VMAXFCDriver(configuration=configuration) + iscsi_fake_xml = FakeXML().create_fake_config_file( + config_group, self.data.port_group_name_i) + iscsi_config = FakeConfiguration( + iscsi_fake_xml, config_group, + replication_device=self.replication_device) + iscsi_driver = iscsi.VMAXISCSIDriver(configuration=iscsi_config) + self.iscsi_common = iscsi_driver.common self.driver = driver self.common = self.driver.common self.masking = self.common.masking @@ -6087,6 +6262,16 @@ class VMAXCommonReplicationTest(test.TestCase): self.fake_xml, config_group, replication_device=self.async_rep_device) self.async_driver = fc.VMAXFCDriver(configuration=async_configuration) + self.metro_rep_device = { + 'target_device_id': self.data.remote_array, + 'remote_port_group': self.data.port_group_name_f, + 'remote_pool': self.data.srp2, + 'rdf_group_label': self.data.rdf_group_name, + 'allow_extend': 'True', 'mode': 'metro'} + metro_configuration = FakeConfiguration( + self.fake_xml, config_group, + replication_device=self.metro_rep_device) + self.metro_driver = fc.VMAXFCDriver(configuration=metro_configuration) def test_get_replication_info(self): self.common._get_replication_info() @@ -6186,6 +6371,17 @@ class VMAXCommonReplicationTest(test.TestCase): self.common._unmap_lun(self.data.test_volume, self.data.connector) mock_es.assert_called_once_with(extra_specs, rep_config) + @mock.patch.object(common.VMAXCommon, '_remove_members') + @mock.patch.object(common.VMAXCommon, + '_get_replication_extra_specs', + return_value=VMAXCommonData.rep_extra_specs) + @mock.patch.object(utils.VMAXUtils, 'is_metro_device', return_value=True) + def test_unmap_lun_metro(self, mock_md, mock_es, mock_rm): + extra_specs = deepcopy(self.extra_specs) + extra_specs[utils.PORTGROUPNAME] = self.data.port_group_name_f + self.common._unmap_lun(self.data.test_volume, self.data.connector) + self.assertEqual(2, mock_rm.call_count) + @mock.patch.object(utils.VMAXUtils, 'is_volume_failed_over', return_value=True) def test_initialize_connection_vol_failed_over(self, mock_fo): @@ -6201,6 +6397,57 @@ class VMAXCommonReplicationTest(test.TestCase): self.data.test_volume, self.data.connector) mock_es.assert_called_once_with(extra_specs, rep_config) + @mock.patch.object(utils.VMAXUtils, 'is_metro_device', return_value=True) + def test_initialize_connection_vol_metro(self, mock_md): + metro_connector = deepcopy(self.data.connector) + metro_connector['multipath'] = True + info_dict = self.common.initialize_connection( + self.data.test_volume, metro_connector) + ref_dict = {'array': self.data.array, + 'device_id': self.data.device_id, + 'hostlunid': 3, + 'maskingview': self.data.masking_view_name_f, + 'metro_hostlunid': 3} + self.assertEqual(ref_dict, info_dict) + + @mock.patch.object(rest.VMAXRest, 'get_iscsi_ip_address_and_iqn', + return_value=([VMAXCommonData.ip], + VMAXCommonData.initiator)) + @mock.patch.object(common.VMAXCommon, '_get_replication_extra_specs', + return_value=VMAXCommonData.rep_extra_specs) + @mock.patch.object(utils.VMAXUtils, 'is_metro_device', return_value=True) + def test_initialize_connection_vol_metro_iscsi(self, mock_md, mock_es, + mock_ip): + metro_connector = deepcopy(self.data.connector) + metro_connector['multipath'] = True + info_dict = self.iscsi_common.initialize_connection( + self.data.test_volume, metro_connector) + ref_dict = {'array': self.data.array, + 'device_id': self.data.device_id, + 'hostlunid': 3, + 'maskingview': self.data.masking_view_name_f, + 'ip_and_iqn': [{'ip': self.data.ip, + 'iqn': self.data.initiator}], + 'metro_hostlunid': 3, + 'is_multipath': True, + 'metro_ip_and_iqn': [{'ip': self.data.ip, + 'iqn': self.data.initiator}]} + self.assertEqual(ref_dict, info_dict) + + @mock.patch.object(utils.VMAXUtils, 'is_metro_device', return_value=True) + def test_initialize_connection_no_multipath_iscsi(self, mock_md): + info_dict = self.iscsi_common.initialize_connection( + self.data.test_volume, self.data.connector) + self.assertIsNone(info_dict) + + def test_attach_metro_volume(self): + rep_extra_specs = deepcopy(VMAXCommonData.rep_extra_specs) + rep_extra_specs[utils.PORTGROUPNAME] = self.data.port_group_name_f + hostlunid, remote_port_group = self.common._attach_metro_volume( + self.data.test_volume, self.data.connector, + self.data.extra_specs, rep_extra_specs) + self.assertEqual(self.data.port_group_name_f, remote_port_group) + @mock.patch.object(rest.VMAXRest, 'is_vol_in_rep_session', return_value=(False, False, None)) @mock.patch.object(common.VMAXCommon, 'extend_volume_is_replicated') @@ -6267,27 +6514,25 @@ class VMAXCommonReplicationTest(test.TestCase): 'device_id': self.data.device_id2}, rep_data) mock_create.assert_not_called() - @mock.patch.object(masking.VMAXMasking, 'remove_and_reset_members') @mock.patch.object(common.VMAXCommon, '_cleanup_remote_target') - def test_cleanup_lun_replication_success(self, mock_clean, mock_rm): + def test_cleanup_lun_replication_success(self, mock_clean): rep_extra_specs = deepcopy(self.data.rep_extra_specs) rep_extra_specs[utils.PORTGROUPNAME] = self.data.port_group_name_f self.common.cleanup_lun_replication( self.data.test_volume, "1", self.data.device_id, self.extra_specs) mock_clean.assert_called_once_with( - self.data.array, self.data.remote_array, self.data.device_id, + self.data.array, self.data.test_volume, + self.data.remote_array, self.data.device_id, self.data.device_id2, self.data.rdf_group_no, "1", rep_extra_specs) - mock_rm.assert_called_once_with( - self.data.remote_array, self.data.test_volume, - self.data.device_id2, "1", rep_extra_specs, False) # Cleanup legacy replication self.common.cleanup_lun_replication( self.data.test_legacy_vol, "1", self.data.device_id, self.extra_specs) mock_clean.assert_called_once_with( - self.data.array, self.data.remote_array, self.data.device_id, + self.data.array, self.data.test_volume, + self.data.remote_array, self.data.device_id, self.data.device_id2, self.data.rdf_group_no, "1", rep_extra_specs) @@ -6300,30 +6545,86 @@ class VMAXCommonReplicationTest(test.TestCase): self.extra_specs) mock_clean.assert_not_called() - def test_cleanup_lun_replication_exception(self): + @mock.patch.object( + common.VMAXCommon, 'get_remote_target_device', + return_value=(VMAXCommonData.device_id2, '', '', '', '')) + @mock.patch.object(common.VMAXCommon, + '_add_volume_to_async_rdf_managed_grp') + def test_cleanup_lun_replication_exception(self, mock_add, mock_tgt): self.assertRaises(exception.VolumeBackendAPIException, self.common.cleanup_lun_replication, self.data.test_volume, "1", self.data.device_id, self.extra_specs) + # is metro or async volume + extra_specs = deepcopy(self.extra_specs) + extra_specs[utils.REP_MODE] = utils.REP_METRO + self.assertRaises(exception.VolumeBackendAPIException, + self.common.cleanup_lun_replication, + self.data.test_volume, "1", self.data.device_id, + extra_specs) + mock_add.assert_called_once() + @mock.patch.object(common.VMAXCommon, '_cleanup_metro_target') + @mock.patch.object(masking.VMAXMasking, 'remove_vol_from_storage_group') @mock.patch.object(common.VMAXCommon, '_delete_from_srp') @mock.patch.object(provision.VMAXProvision, 'break_rdf_relationship') - def test_cleanup_remote_target(self, mock_break, mock_del): + def test_cleanup_remote_target(self, mock_break, mock_del, + mock_rm, mock_clean_metro): with mock.patch.object(self.rest, 'are_vols_rdf_paired', return_value=(False, '', '')): self.common._cleanup_remote_target( - self.data.array, self.data.remote_array, self.data.device_id, + self.data.array, self.data.test_volume, + self.data.remote_array, self.data.device_id, self.data.device_id2, self.data.rdf_group_name, "vol1", self.data.rep_extra_specs) mock_break.assert_not_called() self.common._cleanup_remote_target( - self.data.array, self.data.remote_array, self.data.device_id, + self.data.array, self.data.test_volume, + self.data.remote_array, self.data.device_id, self.data.device_id2, self.data.rdf_group_name, "vol1", self.data.rep_extra_specs) mock_break.assert_called_once_with( self.data.array, self.data.device_id, self.data.device_id2, self.data.rdf_group_name, self.data.rep_extra_specs, "Synchronized") + # is metro volume + with mock.patch.object(self.utils, 'is_metro_device', + return_value=True): + self.common._cleanup_remote_target( + self.data.array, self.data.test_volume, + self.data.remote_array, self.data.device_id, + self.data.device_id2, self.data.rdf_group_name, + "vol1", self.data.rep_extra_specs) + mock_clean_metro.assert_called_once() + + def test_cleanup_remote_target_exception(self): + extra_specs = deepcopy(self.data.rep_extra_specs) + extra_specs['mode'] = utils.REP_METRO + self.assertRaises(exception.VolumeBackendAPIException, + self.metro_driver.common._cleanup_remote_target, + self.data.array, self.data.test_volume, + self.data.remote_array, + self.data.device_id, self.data.device_id2, + self.data.rdf_group_name, "vol1", extra_specs) + + @mock.patch.object(provision.VMAXProvision, 'enable_group_replication') + @mock.patch.object(rest.VMAXRest, 'get_num_vols_in_sg', + side_effect=[2, 0]) + def test_cleanup_metro_target(self, mock_vols, mock_enable): + # allow delete is True + specs = {'allow_del_metro': True} + for x in range(0, 2): + self.common._cleanup_metro_target( + self.data.array, self.data.device_id, self.data.device_id2, + self.data.rdf_group_no, specs) + mock_enable.assert_called_once() + # allow delete is False + specs['allow_del_metro'] = False + self.assertRaises(exception.VolumeBackendAPIException, + self.common._cleanup_metro_target, + self.data.array, self.data.device_id, + self.data.device_id2, + self.data.rdf_group_no, specs) @mock.patch.object(common.VMAXCommon, '_remove_vol_and_cleanup_replication') @@ -6430,7 +6731,7 @@ class VMAXCommonReplicationTest(test.TestCase): side_effect=[None, VMAXCommonData.device_id, VMAXCommonData.device_id, VMAXCommonData.device_id]) @mock.patch.object( - common.VMAXCommon, 'get_masking_views_from_volume', + common.VMAXCommon, '_get_masking_views_from_volume', side_effect=['OS-host-MV', None, exception.VolumeBackendAPIException]) def test_recover_volumes_on_failback(self, mock_mv, mock_dev): recovery1 = self.common.recover_volumes_on_failback( @@ -6498,6 +6799,13 @@ class VMAXCommonReplicationTest(test.TestCase): self.data.failed_resource, self.data.test_volume, self.data.device_id, 'vol1', '1', self.data.extra_specs_rep_enabled) + with mock.patch.object(self.utils, 'is_metro_device', + return_value=True): + self.assertRaises(exception.VolumeBackendAPIException, + self.common.extend_volume_is_replicated, + self.data.array, self.data.test_volume, + self.data.device_id, 'vol1', '1', + self.data.extra_specs_rep_enabled) @mock.patch.object(common.VMAXCommon, 'add_volume_to_replication_group') @mock.patch.object(masking.VMAXMasking, 'remove_and_reset_members') @@ -6545,14 +6853,12 @@ class VMAXCommonReplicationTest(test.TestCase): # Path one - disable compression extra_specs1 = deepcopy(self.extra_specs) extra_specs1[utils.DISABLECOMPRESSION] = "true" - ref_specs1 = deepcopy(self.data.rep_extra_specs) - ref_specs1[utils.PORTGROUPNAME] = self.data.port_group_name_f + ref_specs1 = deepcopy(self.data.rep_extra_specs2) rep_extra_specs1 = self.common._get_replication_extra_specs( extra_specs1, rep_config) self.assertEqual(ref_specs1, rep_extra_specs1) # Path two - disable compression, not all flash - ref_specs2 = deepcopy(self.data.rep_extra_specs) - ref_specs2[utils.PORTGROUPNAME] = self.data.port_group_name_f + ref_specs2 = deepcopy(self.data.rep_extra_specs2) with mock.patch.object(self.rest, 'is_compression_capable', return_value=False): rep_extra_specs2 = self.common._get_replication_extra_specs( diff --git a/cinder/volume/drivers/dell_emc/vmax/common.py b/cinder/volume/drivers/dell_emc/vmax/common.py index fefa7be7b26..8de5edf2d07 100644 --- a/cinder/volume/drivers/dell_emc/vmax/common.py +++ b/cinder/volume/drivers/dell_emc/vmax/common.py @@ -187,6 +187,9 @@ class VMAXCommon(object): if self.active_backend_id == self.rep_config['array']: self.failover = True self.extend_replicated_vol = self.rep_config['allow_extend'] + self.allow_delete_metro = ( + self.rep_config['allow_delete_metro'] + if self.rep_config.get('allow_delete_metro') else False) # use self.replication_enabled for update_volume_stats self.replication_enabled = True LOG.debug("The replication configuration is %(rep_config)s.", @@ -466,9 +469,10 @@ class VMAXCommon(object): :param connector: the connector Object """ extra_specs = self._initial_setup(volume) + rep_extra_specs = self._get_replication_extra_specs( + extra_specs, self.rep_config) if self.utils.is_volume_failed_over(volume): - extra_specs = self._get_replication_extra_specs( - extra_specs, self.rep_config) + extra_specs = rep_extra_specs volume_name = volume.name async_grp = None LOG.info("Unmap volume: %(volume)s.", @@ -492,8 +496,7 @@ class VMAXCommon(object): return source_nf_sg = None array = extra_specs[utils.ARRAY] - if (self.utils.is_replication_enabled(extra_specs) and - extra_specs.get(utils.REP_MODE, None) == utils.REP_ASYNC): + if self.utils.does_vol_need_rdf_management_group(extra_specs): async_grp = self.utils.get_async_rdf_managed_grp_name( self.rep_config) if len(source_storage_group_list) > 1: @@ -504,11 +507,25 @@ class VMAXCommon(object): if source_nf_sg: # Remove volume from non fast storage group self.masking.remove_volume_from_sg( - array, device_info['device_id'], volume_name, storage_group, + array, device_info['device_id'], volume_name, source_nf_sg, extra_specs) else: self._remove_members(array, volume, device_info['device_id'], extra_specs, connector, async_grp=async_grp) + if self.utils.is_metro_device(self.rep_config, extra_specs): + # Need to remove from remote masking view + device_info, __, __ = (self.find_host_lun_id( + volume, host, extra_specs, rep_extra_specs)) + if 'hostlunid' in device_info: + self._remove_members( + rep_extra_specs[utils.ARRAY], volume, + device_info['device_id'], + rep_extra_specs, connector, async_grp=async_grp) + else: + # Make an attempt to clean up initiator group + self.masking.attempt_ig_cleanup( + connector, self.protocol, rep_extra_specs[utils.ARRAY], + True) def initialize_connection(self, volume, connector): """Initializes the connection and returns device and connection info. @@ -540,13 +557,20 @@ class VMAXCommon(object): """ extra_specs = self._initial_setup(volume) is_multipath = connector.get('multipath', False) - + rep_extra_specs = self._get_replication_extra_specs( + extra_specs, self.rep_config) + remote_port_group = None volume_name = volume.name LOG.info("Initialize connection: %(volume)s.", {'volume': volume_name}) + if (self.utils.is_metro_device(self.rep_config, extra_specs) + and not is_multipath and self.protocol.lower() == 'iscsi'): + LOG.warning("Multipathing is not correctly enabled " + "on your system.") + return + if self.utils.is_volume_failed_over(volume): - extra_specs = self._get_replication_extra_specs( - extra_specs, self.rep_config) + extra_specs = rep_extra_specs device_info_dict, is_live_migration, source_storage_group_list = ( self.find_host_lun_id(volume, connector['host'], extra_specs)) masking_view_dict = self._populate_masking_dict( @@ -565,6 +589,21 @@ class VMAXCommon(object): self.get_port_group_from_masking_view( extra_specs[utils.ARRAY], device_info_dict['maskingview'])) + if self.utils.is_metro_device(self.rep_config, extra_specs): + remote_info_dict, __, __ = ( + self.find_host_lun_id(volume, connector['host'], + extra_specs, rep_extra_specs)) + if remote_info_dict.get('hostlunid') is None: + # Need to attach on remote side + metro_host_lun, remote_port_group = ( + self._attach_metro_volume( + volume, connector, extra_specs, rep_extra_specs)) + else: + metro_host_lun = remote_info_dict['hostlunid'] + remote_port_group = self.get_port_group_from_masking_view( + rep_extra_specs[utils.ARRAY], + remote_info_dict['maskingview']) + device_info_dict['metro_hostlunid'] = metro_host_lun else: if is_live_migration: @@ -597,6 +636,11 @@ class VMAXCommon(object): self._attach_volume( volume, connector, extra_specs, masking_view_dict, is_live_migration)) + if self.utils.is_metro_device(self.rep_config, extra_specs): + # Need to attach on remote side + metro_host_lun, remote_port_group = self._attach_metro_volume( + volume, connector, extra_specs, rep_extra_specs) + device_info_dict['metro_hostlunid'] = metro_host_lun if is_live_migration: self.masking.post_live_migration( masking_view_dict, extra_specs) @@ -604,17 +648,46 @@ class VMAXCommon(object): device_info_dict['ip_and_iqn'] = ( self._find_ip_and_iqns( extra_specs[utils.ARRAY], port_group_name)) + if self.utils.is_metro_device(self.rep_config, extra_specs): + device_info_dict['metro_ip_and_iqn'] = ( + self._find_ip_and_iqns( + rep_extra_specs[utils.ARRAY], remote_port_group)) device_info_dict['is_multipath'] = is_multipath return device_info_dict + def _attach_metro_volume(self, volume, connector, + extra_specs, rep_extra_specs): + """Helper method to attach a metro volume. + + Metro protected volumes point to two VMAX devices on different arrays, + which are presented as a single device to the host. This method + masks the remote device to the host. + :param volume: the volume object + :param connector: the connector dict + :param rep_extra_specs: replication extra specifications + :return: hostlunid, remote_port_group + """ + remote_mv_dict = self._populate_masking_dict( + volume, connector, extra_specs, rep_extra_specs) + remote_info_dict, remote_port_group = ( + self._attach_volume( + volume, connector, extra_specs, remote_mv_dict, + rep_extra_specs=rep_extra_specs)) + remote_port_group = self.get_port_group_from_masking_view( + rep_extra_specs[utils.ARRAY], remote_info_dict['maskingview']) + return remote_info_dict['hostlunid'], remote_port_group + def _attach_volume(self, volume, connector, extra_specs, - masking_view_dict, is_live_migration=False): + masking_view_dict, is_live_migration=False, + rep_extra_specs=None): """Attach a volume to a host. :param volume: the volume object :param connector: the connector object :param extra_specs: extra specifications :param masking_view_dict: masking view information + :param is_live_migration: flag to indicate live migration + :param rep_extra_specs: rep extra specs are passed if metro device :returns: dict -- device_info_dict String -- port group name :raises: VolumeBackendAPIException @@ -624,14 +697,15 @@ class VMAXCommon(object): masking_view_dict['isLiveMigration'] = True else: masking_view_dict['isLiveMigration'] = False + m_specs = extra_specs if rep_extra_specs is None else rep_extra_specs rollback_dict = self.masking.setup_masking_view( masking_view_dict[utils.ARRAY], volume, - masking_view_dict, extra_specs) + masking_view_dict, m_specs) # Find host lun id again after the volume is exported to the host. device_info_dict, __, __ = self.find_host_lun_id( - volume, connector['host'], extra_specs) + volume, connector['host'], extra_specs, rep_extra_specs) if 'hostlunid' not in device_info_dict: # Did not successfully attach to host, # so a rollback for FAST is required. @@ -639,7 +713,7 @@ class VMAXCommon(object): "Cannot retrieve hostlunid. ", {'vol': volume_name}) self.masking.check_if_rollback_action_for_masking_required( - masking_view_dict[utils.ARRAY], + masking_view_dict[utils.ARRAY], volume, masking_view_dict[utils.DEVICE_ID], rollback_dict) exception_message = (_("Error Attaching volume %(vol)s.") @@ -901,6 +975,9 @@ class VMAXCommon(object): extra_specs[utils.IS_RE] = True if self.rep_config and self.rep_config.get('mode'): extra_specs[utils.REP_MODE] = self.rep_config['mode'] + if self.rep_config and self.rep_config.get(utils.METROBIAS): + extra_specs[utils.METROBIAS] = self.rep_config[ + utils.METROBIAS] if register_config_file: config_file = self._register_config_file_from_config_group( config_group) @@ -941,25 +1018,31 @@ class VMAXCommon(object): return founddevice_id - def find_host_lun_id(self, volume, host, extra_specs): + def find_host_lun_id(self, volume, host, extra_specs, + rep_extra_specs=None): """Given the volume dict find the host lun id for a volume. :param volume: the volume dict :param host: host from connector (can be None on a force-detach) :param extra_specs: the extra specs + :param rep_extra_specs: rep extra specs, passed in if metro device :returns: dict -- the data dict """ maskedvols = {} is_live_migration = False volume_name = volume.name device_id = self._find_device_on_array(volume, extra_specs) + if rep_extra_specs is not None: + device_id = self.get_remote_target_device( + extra_specs[utils.ARRAY], volume, device_id)[0] + extra_specs = rep_extra_specs host_name = self.utils.get_host_short_name(host) if host else None if device_id: array = extra_specs[utils.ARRAY] source_storage_group_list = ( self.rest.get_storage_groups_from_volume(array, device_id)) # return only masking views for this host - maskingviews = self.get_masking_views_from_volume( + maskingviews = self._get_masking_views_from_volume( array, device_id, host_name, source_storage_group_list) for maskingview in maskingviews: @@ -1002,9 +1085,25 @@ class VMAXCommon(object): return maskedvols, is_live_migration, source_storage_group_list - def get_masking_views_from_volume(self, array, device_id, host, - storage_group_list=None): - """Retrieve masking view list for a volume. + def get_masking_views_from_volume(self, array, volume, device_id, host): + """Get all masking views from a volume. + + :param array: array serial number + :param volume: the volume object + :param device_id: the volume device id + :param host: the host + :return: masking view list, is metro + """ + is_metro = False + extra_specs = self._initial_setup(volume) + mv_list = self._get_masking_views_from_volume(array, device_id, host) + if self.utils.is_metro_device(self.rep_config, extra_specs): + is_metro = True + return mv_list, is_metro + + def _get_masking_views_from_volume(self, array, device_id, host, + storage_group_list=None): + """Helper function to retrieve masking view list for a volume. :param array: array serial number :param device_id: the volume device id @@ -1112,15 +1211,29 @@ class VMAXCommon(object): raise exception.VolumeBackendAPIException(data=exception_message) return extra_specs - def _populate_masking_dict(self, volume, connector, extra_specs): + def _populate_masking_dict(self, volume, connector, + extra_specs, rep_extra_specs=None): """Get all the names of the maskingview and sub-components. :param volume: the volume object :param connector: the connector object :param extra_specs: extra specifications + :param rep_extra_specs: replication extra specs, if metro volume :returns: dict -- a dictionary with masking view information """ masking_view_dict = {} + volume_name = volume.name + device_id = self._find_device_on_array(volume, extra_specs) + if rep_extra_specs is not None: + device_id = self.get_remote_target_device( + extra_specs[utils.ARRAY], volume, device_id)[0] + extra_specs = rep_extra_specs + if not device_id: + exception_message = (_("Cannot retrieve volume %(vol)s " + "from the array. ") % {'vol': volume_name}) + LOG.exception(exception_message) + raise exception.VolumeBackendAPIException(exception_message) + host_name = connector['host'] unique_name = self.utils.truncate_string(extra_specs[utils.SRP], 12) protocol = self.utils.get_short_protocol_type(self.protocol) @@ -1178,13 +1291,6 @@ class VMAXCommon(object): masking_view_dict[utils.PARENT_SG_NAME] = ("%(prefix)s-SG" % {'prefix': mv_prefix}) - volume_name = volume.name - device_id = self._find_device_on_array(volume, extra_specs) - if not device_id: - exception_message = (_("Cannot retrieve volume %(vol)s " - "from the array. ") % {'vol': volume_name}) - LOG.exception(exception_message) - raise exception.VolumeBackendAPIException(exception_message) masking_view_dict[utils.IG_NAME] = ( ("OS-%(shortHostName)s-%(protocol)s-IG" @@ -1550,13 +1656,13 @@ class VMAXCommon(object): :param extra_specs: the extra specifications :param volume: the volume object """ - # Remove from any storage groups - self.masking.remove_and_reset_members( - array, volume, device_id, volume_name, extra_specs, False) # Cleanup remote replication if self.utils.is_replication_enabled(extra_specs): self.cleanup_lun_replication(volume, volume_name, device_id, extra_specs) + # Remove from any storage groups + self.masking.remove_and_reset_members( + array, volume, device_id, volume_name, extra_specs, False) def get_target_wwns_from_masking_view( self, volume, connector): @@ -1566,16 +1672,36 @@ class VMAXCommon(object): :param connector: the connector dict :returns: list -- the target WWN list """ - target_wwns = [] + metro_wwns = [] host = connector['host'] short_host_name = self.utils.get_host_short_name(host) extra_specs = self._initial_setup(volume) + rep_extra_specs = self._get_replication_extra_specs( + extra_specs, self.rep_config) if self.utils.is_volume_failed_over(volume): - extra_specs = self._get_replication_extra_specs( - extra_specs, self.rep_config) - array = extra_specs[utils.ARRAY] + extra_specs = rep_extra_specs device_id = self._find_device_on_array(volume, extra_specs) - masking_view_list = self.get_masking_views_from_volume( + target_wwns = self._get_target_wwns_from_masking_view( + device_id, short_host_name, extra_specs) + if self.utils.is_metro_device(self.rep_config, extra_specs): + remote_device_id = self.get_remote_target_device( + extra_specs[utils.ARRAY], volume, device_id)[0] + metro_wwns = self._get_target_wwns_from_masking_view( + remote_device_id, short_host_name, rep_extra_specs) + return target_wwns, metro_wwns + + def _get_target_wwns_from_masking_view( + self, device_id, short_host_name, extra_specs): + """Helper function to get wwns from a masking view. + + :param device_id: the device id + :param short_host_name: the short host name + :param extra_specs: the extra specs + :return: target wwns -- list + """ + target_wwns = [] + array = extra_specs[utils.ARRAY] + masking_view_list = self._get_masking_views_from_volume( array, device_id, short_host_name) if masking_view_list is not None: portgroup = self.get_port_group_from_masking_view( @@ -2195,8 +2321,7 @@ class VMAXCommon(object): array, volume, device_id, rdf_group_no, self.rep_config, target_name, remote_array, target_device_id, extra_specs) - rep_mode = extra_specs.get(utils.REP_MODE, None) - if rep_mode == utils.REP_ASYNC: + if self.utils.does_vol_need_rdf_management_group(extra_specs): self._add_volume_to_async_rdf_managed_grp( array, device_id, source_name, remote_array, target_device_id, extra_specs) @@ -2245,7 +2370,7 @@ class VMAXCommon(object): device_id, extra_specs): """Cleanup target volume on delete. - Extra logic if target is last in group. + Extra logic if target is last in group, or is a metro volume. :param volume: the volume object :param volume_name: the volume name :param device_id: the device id @@ -2274,11 +2399,8 @@ class VMAXCommon(object): if target_device is not None: # Clean-up target - self.masking.remove_and_reset_members( - remote_array, volume, target_device, volume_name, - rep_extra_specs, False) self._cleanup_remote_target( - array, remote_array, device_id, target_device, + array, volume, remote_array, device_id, target_device, rdf_group_no, volume_name, rep_extra_specs) LOG.info('Successfully destroyed replication for ' 'volume: %(volume)s', @@ -2288,7 +2410,8 @@ class VMAXCommon(object): 'replication-enabled volume: %(volume)s', {'volume': volume_name}) except Exception as e: - if extra_specs.get(utils.REP_MODE, None) == utils.REP_ASYNC: + if extra_specs.get(utils.REP_MODE, None) in [ + utils.REP_ASYNC, utils.REP_METRO]: (target_device, remote_array, rdf_group_no, local_vol_state, pair_state) = ( self.get_remote_target_device( @@ -2309,11 +2432,12 @@ class VMAXCommon(object): raise exception.VolumeBackendAPIException(data=exception_message) def _cleanup_remote_target( - self, array, remote_array, device_id, target_device, + self, array, volume, remote_array, device_id, target_device, rdf_group, volume_name, rep_extra_specs): """Clean-up remote replication target after exception or on deletion. :param array: the array serial number + :param volume: the volume object :param remote_array: the remote array serial number :param device_id: the source device id :param target_device: the target device id @@ -2321,17 +2445,66 @@ class VMAXCommon(object): :param volume_name: the volume name :param rep_extra_specs: replication extra specifications """ + self.masking.remove_and_reset_members( + remote_array, volume, target_device, volume_name, + rep_extra_specs, False) are_vols_paired, local_vol_state, pair_state = ( self.rest.are_vols_rdf_paired( array, remote_array, device_id, target_device)) if are_vols_paired: - # Break the sync relationship. - self.provision.break_rdf_relationship( - array, device_id, target_device, rdf_group, - rep_extra_specs, pair_state) + is_metro = self.utils.is_metro_device( + self.rep_config, rep_extra_specs) + if is_metro: + rep_extra_specs['allow_del_metro'] = self.allow_delete_metro + self._cleanup_metro_target( + array, device_id, target_device, + rdf_group, rep_extra_specs) + else: + # Break the sync relationship. + self.provision.break_rdf_relationship( + array, device_id, target_device, rdf_group, + rep_extra_specs, pair_state) self._delete_from_srp( remote_array, target_device, volume_name, rep_extra_specs) + @coordination.synchronized('emc-rg-{rdf_group}') + def _cleanup_metro_target(self, array, device_id, target_device, + rdf_group, rep_extra_specs): + """Helper function to cleanup a metro remote target. + + :param array: the array serial number + :param device_id: the device id + :param target_device: the target device id + :param rdf_group: the rdf group number + :param rep_extra_specs: the rep extra specs + """ + if rep_extra_specs['allow_del_metro']: + metro_grp = self.utils.get_async_rdf_managed_grp_name( + self.rep_config) + self.provision.break_metro_rdf_pair( + array, device_id, target_device, rdf_group, + rep_extra_specs, metro_grp) + # Remove the volume from the metro_grp + self.masking.remove_volume_from_sg(array, device_id, 'metro_vol', + metro_grp, rep_extra_specs) + # Resume I/O on the RDF links for any remaining volumes + if self.rest.get_num_vols_in_sg(array, metro_grp) > 0: + LOG.info("Resuming I/O for all volumes in the RDF group: " + "%(rdfg)s", {'rdfg': device_id}) + self.provision.enable_group_replication( + array, metro_grp, rdf_group, + rep_extra_specs, establish=True) + else: + exception_message = ( + _("Deleting a Metro-protected replicated volume is " + "not permitted on this backend %(backend)s. " + "Please contact your administrator.") + % {'backend': self.configuration.safe_get( + 'volume_backend_name')}) + LOG.error(exception_message) + raise exception.VolumeBackendAPIException( + data=exception_message) + def _cleanup_replication_source( self, array, volume, volume_name, volume_dict, extra_specs): """Cleanup a remote replication source volume on failure. @@ -2554,8 +2727,8 @@ class VMAXCommon(object): volume_update['updates'] = {'status': 'error'} else: try: - maskingview = self.get_masking_views_from_volume( - extra_specs[utils.ARRAY], device_id, '') + maskingview = self._get_masking_views_from_volume( + extra_specs[utils.ARRAY], device_id, None) except Exception: maskingview = None LOG.debug("Unable to determine if volume is in masking view.") @@ -2599,8 +2772,10 @@ class VMAXCommon(object): new_size, extra_specs): """Extend a replication-enabled volume. - Cannot extend volumes in a synchronization pair. Must first break the - relationship, extend them separately, then recreate the pair + Cannot extend volumes in a synchronization pair where the source + and/or target arrays are running HyperMax versions < 5978, or for + Metro-enabled volumes. Must first break the relationship, extend + them separately, then recreate the pair. :param array: the array serial number :param volume: the volume objcet :param device_id: the volume device id @@ -2608,14 +2783,18 @@ class VMAXCommon(object): :param new_size: the new size the volume should be :param extra_specs: extra specifications """ - ode_replication = False - if self.utils.is_replication_enabled(extra_specs): - if self.rest.is_next_gen_array(array): - # Check if remote array is next gen - __, remote_array = self.get_rdf_details(array) - if self.rest.is_next_gen_array(remote_array): - ode_replication = True - if self.extend_replicated_vol is True or ode_replication is True: + ode_replication, allow_extend = False, self.extend_replicated_vol + if (self.rest.is_next_gen_array(array) + and not self.utils.is_metro_device( + self.rep_config, extra_specs)): + # Check if remote array is next gen + __, remote_array = self.get_rdf_details(array) + if self.rest.is_next_gen_array(remote_array): + ode_replication = True + if (self.utils.is_metro_device(self.rep_config, extra_specs) + and not self.allow_delete_metro): + allow_extend = False + if allow_extend is True or ode_replication is True: try: (target_device, remote_array, rdf_group, local_vol_state, pair_state) = ( @@ -2627,10 +2806,14 @@ class VMAXCommon(object): # Volume must be removed from replication (storage) group # before the replication relationship can be ended (cannot # have a mix of replicated and non-replicated volumes as - # the SRDF groups become unmanageable). + # the SRDF groups become unmanageable), but + # leave the vol in metro management group for now + metro_grp = self.utils.get_async_rdf_managed_grp_name( + self.rep_config) if self.utils.is_metro_device( + self.rep_config, rep_extra_specs) else None self.masking.remove_and_reset_members( array, volume, device_id, volume_name, - extra_specs, False) + extra_specs, False, async_grp=metro_grp) # Repeat on target side self.masking.remove_and_reset_members( @@ -2638,9 +2821,17 @@ class VMAXCommon(object): rep_extra_specs, False) LOG.info("Breaking replication relationship...") - self.provision.break_rdf_relationship( - array, device_id, target_device, - rdf_group, rep_extra_specs, pair_state) + if self.utils.is_metro_device( + self.rep_config, rep_extra_specs): + rep_extra_specs['allow_del_metro'] = ( + self.allow_delete_metro) + self._cleanup_metro_target( + array, device_id, target_device, + rdf_group, rep_extra_specs) + else: + self.provision.break_rdf_relationship( + array, device_id, target_device, rdf_group, + rep_extra_specs, pair_state) # Extend the target volume LOG.info("Extending target volume...") @@ -2710,6 +2901,9 @@ class VMAXCommon(object): remote_array, volume, target_device, target_name, rep_extra_specs, False) + # Check if volume is a copy session target + self._sync_check(array, device_id, target_name, + extra_specs, tgt_only=True) # Establish replication relationship rdf_dict = self.rest.create_rdf_device_pair( array, device_id, rdf_group_no, target_device, remote_array, @@ -2729,11 +2923,8 @@ class VMAXCommon(object): "volume and returning source volume to default storage " "group. Volume name: %(name)s "), {'name': target_name}) - self.masking.remove_and_reset_members( - remote_array, volume, target_device, target_name, - rep_extra_specs, False) self._cleanup_remote_target( - array, remote_array, device_id, target_device, + array, volume, remote_array, device_id, target_device, rdf_group_no, target_name, rep_extra_specs) # Re-throw the exception. exception_message = (_("Remote replication failed with exception:" @@ -2787,6 +2978,9 @@ class VMAXCommon(object): :param rep_config: the replication configuration :returns: repExtraSpecs - dict """ + if not self.utils.is_replication_enabled(extra_specs): + # Skip this if the volume is not replicated + return rep_extra_specs = deepcopy(extra_specs) rep_extra_specs[utils.ARRAY] = rep_config['array'] rep_extra_specs[utils.SRP] = rep_config['srp'] @@ -2876,9 +3070,10 @@ class VMAXCommon(object): raise NotImplementedError() if group.is_replicated: if (self.rep_config and self.rep_config.get('mode') - and self.rep_config['mode'] == utils.REP_ASYNC): + and self.rep_config['mode'] + in [utils.REP_ASYNC, utils.REP_METRO]): msg = _('Replication groups are not supported ' - 'for use with Asynchronous replication.') + 'for use with Asynchronous replication or Metro.') raise exception.InvalidInput(reason=msg) model_update = {'status': fields.GroupStatus.AVAILABLE} diff --git a/cinder/volume/drivers/dell_emc/vmax/fc.py b/cinder/volume/drivers/dell_emc/vmax/fc.py index 9fe203b5e16..ce94e581709 100644 --- a/cinder/volume/drivers/dell_emc/vmax/fc.py +++ b/cinder/volume/drivers/dell_emc/vmax/fc.py @@ -86,6 +86,7 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): 3.1.0 - Support for replication groups (Tiramisu) - Deprecate backend xml configuration - Support for async replication (vmax-replication-enhancements) + - Support for SRDF/Metro (vmax-replication-enhancements) """ VERSION = "3.1.0" @@ -228,7 +229,10 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): """ device_info = self.common.initialize_connection( volume, connector) - return self.populate_data(device_info, volume, connector) + if device_info: + return self.populate_data(device_info, volume, connector) + else: + return {} def populate_data(self, device_info, volume, connector): """Populate data dict. @@ -290,6 +294,7 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): loc = volume.provider_location name = ast.literal_eval(loc) host = connector['host'] + zoning_mappings = {} try: array = name['array'] device_id = name['device_id'] @@ -299,8 +304,9 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): LOG.debug("Start FC detach process for volume: %(volume)s.", {'volume': volume.name}) - masking_views = self.common.get_masking_views_from_volume( - array, device_id, host) + masking_views, is_metro = ( + self.common.get_masking_views_from_volume( + array, volume, device_id, host)) if masking_views: portgroup = ( self.common.get_port_group_from_masking_view( @@ -321,10 +327,33 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): 'target_wwns': target_wwns, 'init_targ_map': init_targ_map, 'array': array} - else: + if is_metro: + rep_data = volume.replication_driver_data + name = ast.literal_eval(rep_data) + try: + metro_array = name['array'] + metro_device_id = name['device_id'] + except KeyError: + LOG.error("Cannot get remote Metro device information " + "for zone cleanup. Attempting terminate " + "connection...") + else: + masking_views, __ = ( + self.common.get_masking_views_from_volume( + metro_array, volume, metro_device_id, host)) + if masking_views: + metro_portgroup = ( + self.common.get_port_group_from_masking_view( + metro_array, masking_views[0])) + metro_ig = ( + self.common.get_initiator_group_from_masking_view( + metro_array, masking_views[0])) + zoning_mappings.update( + {'metro_port_group': metro_portgroup, + 'metro_ig': metro_ig, 'metro_array': metro_array}) + if not masking_views: LOG.warning("Volume %(volume)s is not in any masking view.", {'volume': volume.name}) - zoning_mappings = {} return zoning_mappings def _cleanup_zones(self, zoning_mappings): @@ -333,25 +362,35 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): :param zoning_mappings: zoning mapping dict :returns: data - dict """ - LOG.debug("Looking for masking views still associated with " - "Port Group %s.", zoning_mappings['port_group']) - masking_views = self.common.get_common_masking_views( - zoning_mappings['array'], zoning_mappings['port_group'], - zoning_mappings['initiator_group']) + data = {'driver_volume_type': 'fibre_channel', 'data': {}} + try: + LOG.debug("Looking for masking views still associated with " + "Port Group %s.", zoning_mappings['port_group']) + masking_views = self.common.get_common_masking_views( + zoning_mappings['array'], zoning_mappings['port_group'], + zoning_mappings['initiator_group']) + except (KeyError, ValueError, TypeError): + masking_views = [] if masking_views: LOG.debug("Found %(numViews)d MaskingViews.", {'numViews': len(masking_views)}) - data = {'driver_volume_type': 'fibre_channel', 'data': {}} else: # no masking views found - LOG.debug("No MaskingViews were found. Deleting zone.") - data = {'driver_volume_type': 'fibre_channel', - 'data': {'target_wwn': zoning_mappings['target_wwns'], - 'initiator_target_map': - zoning_mappings['init_targ_map']}} + # Check if there any Metro masking views + if zoning_mappings.get('metro_array'): + masking_views = self.common.get_common_masking_views( + zoning_mappings['metro_array'], + zoning_mappings['metro_port_group'], + zoning_mappings['metro_ig']) + if not masking_views: + LOG.debug("No MaskingViews were found. Deleting zone.") + data = {'driver_volume_type': 'fibre_channel', + 'data': {'target_wwn': zoning_mappings['target_wwns'], + 'initiator_target_map': + zoning_mappings['init_targ_map']}} - LOG.debug("Return FC data for zone removal: %(data)s.", - {'data': data}) + LOG.debug("Return FC data for zone removal: %(data)s.", + {'data': data}) return data @@ -364,10 +403,12 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): """ target_wwns, init_targ_map = [], {} initiator_wwns = connector['wwpns'] - fc_targets = self.common.get_target_wwns_from_masking_view( - volume, connector) + fc_targets, metro_fc_targets = ( + self.common.get_target_wwns_from_masking_view( + volume, connector)) if self.zonemanager_lookup_service: + fc_targets.extend(metro_fc_targets) mapping = ( self.zonemanager_lookup_service. get_device_mapping_from_network(initiator_wwns, fc_targets)) @@ -378,8 +419,9 @@ class VMAXFCDriver(san.SanDriver, driver.FibreChannelDriver): init_targ_map[initiator] = map_d['target_port_wwn_list'] else: # No lookup service, pre-zoned case. target_wwns = fc_targets + fc_targets.extend(metro_fc_targets) for initiator in initiator_wwns: - init_targ_map[initiator] = target_wwns + init_targ_map[initiator] = fc_targets return list(set(target_wwns)), init_targ_map diff --git a/cinder/volume/drivers/dell_emc/vmax/iscsi.py b/cinder/volume/drivers/dell_emc/vmax/iscsi.py index 0e8fdc0e568..60d830c63d2 100644 --- a/cinder/volume/drivers/dell_emc/vmax/iscsi.py +++ b/cinder/volume/drivers/dell_emc/vmax/iscsi.py @@ -91,6 +91,7 @@ class VMAXISCSIDriver(san.SanISCSIDriver): 3.1.0 - Support for replication groups (Tiramisu) - Deprecate backend xml configuration - Support for async replication (vmax-replication-enhancements) + - Support for SRDF/Metro (vmax-replication-enhancements) """ VERSION = "3.1.0" @@ -238,7 +239,10 @@ class VMAXISCSIDriver(san.SanISCSIDriver): """ device_info = self.common.initialize_connection( volume, connector) - return self.get_iscsi_dict(device_info, volume) + if device_info: + return self.get_iscsi_dict(device_info, volume) + else: + return {} def get_iscsi_dict(self, device_info, volume): """Populate iscsi dict to pass to nova. @@ -247,6 +251,7 @@ class VMAXISCSIDriver(san.SanISCSIDriver): :param volume: volume object :returns: iscsi dict """ + metro_ip_iqn, metro_host_lun = None, None try: ip_and_iqn = device_info['ip_and_iqn'] is_multipath = device_info['is_multipath'] @@ -257,8 +262,14 @@ class VMAXISCSIDriver(san.SanISCSIDriver): % {'e': six.text_type(e)}) raise exception.VolumeBackendAPIException(data=exception_message) + if device_info.get('metro_ip_and_iqn'): + LOG.debug("Volume is Metro device...") + metro_ip_iqn = device_info['metro_ip_and_iqn'] + metro_host_lun = device_info['metro_hostlunid'] + iscsi_properties = self.vmax_get_iscsi_properties( - volume, ip_and_iqn, is_multipath, host_lun_id) + volume, ip_and_iqn, is_multipath, host_lun_id, + metro_ip_iqn, metro_host_lun) LOG.info("iSCSI properties are: %(props)s", {'props': strutils.mask_dict_password(iscsi_properties)}) @@ -266,7 +277,8 @@ class VMAXISCSIDriver(san.SanISCSIDriver): 'data': iscsi_properties} def vmax_get_iscsi_properties(self, volume, ip_and_iqn, - is_multipath, host_lun_id): + is_multipath, host_lun_id, + metro_ip_iqn, metro_host_lun): """Gets iscsi configuration. We ideally get saved information in the volume entity, but fall back @@ -286,15 +298,32 @@ class VMAXISCSIDriver(san.SanISCSIDriver): :param ip_and_iqn: list of ip and iqn dicts :param is_multipath: flag for multipath :param host_lun_id: the host lun id of the device + :param metro_ip_iqn: metro remote device ip and iqn, if applicable + :param metro_host_lun: metro remote host lun, if applicable :returns: properties """ properties = {} + populate_plurals = False if len(ip_and_iqn) > 1 and is_multipath: + populate_plurals = True + elif len(ip_and_iqn) == 1 and is_multipath and metro_ip_iqn: + populate_plurals = True + if populate_plurals: properties['target_portals'] = ([t['ip'] + ":3260" for t in ip_and_iqn]) properties['target_iqns'] = ([t['iqn'].split(",")[0] for t in ip_and_iqn]) properties['target_luns'] = [host_lun_id] * len(ip_and_iqn) + if metro_ip_iqn: + LOG.info("Volume %(vol)s is metro-enabled - " + "adding additional attachment information", + {'vol': volume.name}) + properties['target_portals'].extend(([t['ip'] + ":3260" for t in + metro_ip_iqn])) + properties['target_iqns'].extend(([t['iqn'].split(",")[0] for t in + metro_ip_iqn])) + properties['target_luns'].extend( + [metro_host_lun] * len(metro_ip_iqn)) properties['target_discovered'] = True properties['target_iqn'] = ip_and_iqn[0]['iqn'].split(",")[0] properties['target_portal'] = ip_and_iqn[0]['ip'] + ":3260" @@ -318,15 +347,8 @@ class VMAXISCSIDriver(san.SanISCSIDriver): def terminate_connection(self, volume, connector, **kwargs): """Disallow connection from connector. - Return empty data if other volumes are in the same zone. - The FibreChannel ZoneManager doesn't remove zones - if there isn't an initiator_target_map in the - return of terminate_connection. - :param volume: the volume object :param connector: the connector object - :returns: dict -- the target_wwns and initiator_target_map if the - zone is to be removed, otherwise empty """ self.common.terminate_connection(volume, connector) diff --git a/cinder/volume/drivers/dell_emc/vmax/masking.py b/cinder/volume/drivers/dell_emc/vmax/masking.py index dedd06a45e7..69d03a0a0a7 100644 --- a/cinder/volume/drivers/dell_emc/vmax/masking.py +++ b/cinder/volume/drivers/dell_emc/vmax/masking.py @@ -76,6 +76,7 @@ class VMAXMasking(object): masking_view_dict[utils.WORKLOAD], masking_view_dict[utils.DISABLECOMPRESSION], masking_view_dict[utils.IS_RE], rep_mode) + rollback_dict = masking_view_dict try: error_message = self._get_or_create_masking_view( @@ -87,6 +88,10 @@ class VMAXMasking(object): "in the masking view is %(storage_name)s.", {'masking_name': maskingview_name, 'storage_name': storagegroup_name}) + rollback_dict['portgroup_name'] = ( + self.rest.get_element_from_masking_view( + serial_number, maskingview_name, portgroup=True)) + except Exception as e: LOG.exception( "Masking View creation or retrieval was not successful " @@ -95,14 +100,6 @@ class VMAXMasking(object): {'maskingview_name': masking_view_dict[utils.MV_NAME]}) error_message = six.text_type(e) - rollback_dict = masking_view_dict - try: - rollback_dict['portgroup_name'] = ( - self.rest.get_element_from_masking_view( - serial_number, maskingview_name, portgroup=True)) - except Exception as e: - error_message = ("Error retrieving port group. Exception " - "received: %(e)s" % {'e': six.text_type(e)}) if 'source_nf_sg' in masking_view_dict: default_sg_name = masking_view_dict['source_nf_sg'] rollback_dict['default_sg_name'] = default_sg_name @@ -856,12 +853,12 @@ class VMAXMasking(object): serial_number, rollback_dict['init_group_name'], rollback_dict['connector']) try: - found_sg_name = ( + found_sg_name_list = ( self.rest.get_storage_groups_from_volume( serial_number, rollback_dict['device_id'])) # Volume is not associated with any storage group so add # it back to the default. - if not found_sg_name: + if not found_sg_name_list: error_message = self._check_adding_volume_to_storage_group( serial_number, device_id, rollback_dict['default_sg_name'], @@ -874,17 +871,24 @@ class VMAXMasking(object): rollback_dict['isLiveMigration'] is True): # Live migration case. # Remove from nonfast storage group to fast sg - self.failed_live_migration(rollback_dict, found_sg_name, + self.failed_live_migration(rollback_dict, found_sg_name_list, rollback_dict[utils.EXTRA_SPECS]) else: - LOG.info("The storage group found is %(found_sg_name)s.", - {'found_sg_name': found_sg_name}) + LOG.info("Volume %(vol_id)s is in %(list_size)d storage" + "groups. The storage groups are %(found_sg_list)s.", + {'vol_id': volume.id, + 'list_size': len(found_sg_name_list), + 'found_sg_list': found_sg_name_list}) # Check the name, see if it is the default storage group # or another. - if found_sg_name != rollback_dict['default_sg_name']: + sg_found = False + for found_sg_name in found_sg_name_list: + if found_sg_name == rollback_dict['default_sg_name']: + sg_found = True + if not sg_found: # Remove it from its current storage group and return it - # to its default masking view if slo is defined. + # to its default storage group if slo is defined. self.remove_and_reset_members( serial_number, volume, device_id, rollback_dict['volume_name'], @@ -997,7 +1001,7 @@ class VMAXMasking(object): return init_group_name def _check_ig_rollback( - self, serial_number, init_group_name, connector): + self, serial_number, init_group_name, connector, force=False): """Check if rollback action is required on an initiator group. If anything goes wrong on a masking view creation, we need to check if @@ -1008,18 +1012,22 @@ class VMAXMasking(object): :param serial_number: the array serial number :param init_group_name: the initiator group name :param connector: the connector object + :param force: force a delete even if no entry in login table """ initiator_names = self.find_initiator_names(connector) found_ig_name = self._find_initiator_group( serial_number, initiator_names) if found_ig_name: if found_ig_name == init_group_name: - host = init_group_name.split("-")[1] - LOG.debug("Searching for masking views associated with " - "%(init_group_name)s", - {'init_group_name': init_group_name}) - self._last_volume_delete_initiator_group( - serial_number, found_ig_name, host) + force = True + if force: + found_ig_name = init_group_name + host = init_group_name.split("-")[1] + LOG.debug("Searching for masking views associated with " + "%(init_group_name)s", + {'init_group_name': init_group_name}) + self._last_volume_delete_initiator_group( + serial_number, found_ig_name, host) @coordination.synchronized("emc-vol-{device_id}") def remove_and_reset_members( @@ -1058,6 +1066,10 @@ class VMAXMasking(object): storagegroup_names = (self.rest.get_storage_groups_from_volume( serial_number, device_id)) if storagegroup_names: + if async_grp is not None: + for index, sg in enumerate(storagegroup_names): + if sg == async_grp: + storagegroup_names.pop(index) if len(storagegroup_names) == 1 and reset is True: move = True elif connector is not None and reset is True: @@ -1072,10 +1084,6 @@ class VMAXMasking(object): extra_specs, connector, move) break else: - if reset is True and async_grp is not None: - for index, sg in enumerate(storagegroup_names): - if sg == async_grp: - storagegroup_names.pop(index) for sg_name in storagegroup_names: self.remove_volume_from_sg( serial_number, device_id, volume_name, sg_name, @@ -1553,7 +1561,8 @@ class VMAXMasking(object): @coordination.synchronized("emc-ig-{ig_name}") def _delete_ig(ig_name): # Check initiator group hasn't been recently deleted - ig_details = self.rest.get_initiator_group(ig_name) + ig_details = self.rest.get_initiator_group( + serial_number, ig_name) if ig_details: LOG.debug( "Last volume associated with the initiator " @@ -1652,3 +1661,20 @@ class VMAXMasking(object): array, source_nf_sg, source_parent_sg, extra_specs) # Delete non fast storage group self.rest.delete_storage_group(array, source_nf_sg) + + def attempt_ig_cleanup(self, connector, protocol, serial_number, force): + """Attempt to cleanup an orphan initiator group + + :param connector: connector object + :param protocol: iscsi or fc + :param serial_number: extra the array serial number + """ + protocol = self.utils.get_short_protocol_type(protocol) + host_name = connector['host'] + short_host_name = self.utils.get_host_short_name(host_name) + init_group = ( + ("OS-%(shortHostName)s-%(protocol)s-IG" + % {'shortHostName': short_host_name, + 'protocol': protocol})) + self._check_ig_rollback( + serial_number, init_group, connector, force) diff --git a/cinder/volume/drivers/dell_emc/vmax/provision.py b/cinder/volume/drivers/dell_emc/vmax/provision.py index f43964db82c..efc8f994e91 100644 --- a/cinder/volume/drivers/dell_emc/vmax/provision.py +++ b/cinder/volume/drivers/dell_emc/vmax/provision.py @@ -402,6 +402,7 @@ class VMAXProvision(object): raise exception.VolumeBackendAPIException(data=exception_message) return '%(slo)s+%(workload)s' % {'slo': slo, 'workload': workload} + @coordination.synchronized('emc-rg-{rdf_group}') def break_rdf_relationship(self, array, device_id, target_device, rdf_group, rep_extra_specs, state): """Break the rdf relationship between a pair of devices. @@ -413,28 +414,40 @@ class VMAXProvision(object): :param rep_extra_specs: replication extra specs :param state: the state of the rdf pair """ - LOG.info("Splitting rdf pair: source device: %(src)s " + LOG.info("Suspending rdf pair: source device: %(src)s " "target device: %(tgt)s.", {'src': device_id, 'tgt': target_device}) - state_check = state.lower() - if state_check == utils.RDF_SYNC_STATE: - self.rest.modify_rdf_device_pair( - array, device_id, rdf_group, rep_extra_specs, split=True) - elif state_check in [utils.RDF_CONSISTENT_STATE, - utils.RDF_SYNCINPROG_STATE]: - if state_check == utils.RDF_SYNCINPROG_STATE: - self.rest.wait_for_rdf_consistent_state( - array, device_id, target_device, - rep_extra_specs, state) - self.rest.modify_rdf_device_pair( - array, device_id, rdf_group, rep_extra_specs, suspend=True) - LOG.info("Deleting rdf pair: source device: %(src)s " - "target device: %(tgt)s.", - {'src': device_id, 'tgt': target_device}) - self.delete_rdf_pair(array, device_id, rdf_group, rep_extra_specs) + if state.lower() == utils.RDF_SYNCINPROG_STATE: + self.rest.wait_for_rdf_consistent_state( + array, device_id, target_device, + rep_extra_specs, state) + self.rest.modify_rdf_device_pair( + array, device_id, rdf_group, rep_extra_specs, suspend=True) + self.delete_rdf_pair(array, device_id, rdf_group, + target_device, rep_extra_specs) + + def break_metro_rdf_pair(self, array, device_id, target_device, + rdf_group, rep_extra_specs, metro_grp): + """Delete replication for a Metro device pair. + + Need to suspend the entire group before we can delete a single pair. + :param array: the array serial number + :param device_id: the device id + :param target_device: the target device id + :param rdf_group: the rdf group number + :param rep_extra_specs: the replication extra specifications + :param metro_grp: the metro storage group name + """ + # Suspend I/O on the RDF links... + LOG.info("Suspending I/O for all volumes in the RDF group: %(rdfg)s", + {'rdfg': rdf_group}) + self.disable_group_replication( + array, metro_grp, rdf_group, rep_extra_specs) + self.delete_rdf_pair(array, device_id, rdf_group, + target_device, rep_extra_specs) def delete_rdf_pair( - self, array, device_id, rdf_group, extra_specs): + self, array, device_id, rdf_group, target_device, extra_specs): """Delete an rdf pairing. If the replication mode is synchronous, only one attempt is required @@ -446,8 +459,12 @@ class VMAXProvision(object): :param array: the array serial number :param device_id: source volume device id :param rdf_group: the rdf group number + :param target_device: the target device :param extra_specs: extra specifications """ + LOG.info("Deleting rdf pair: source device: %(src)s " + "target device: %(tgt)s.", + {'src': device_id, 'tgt': target_device}) if (extra_specs.get(utils.REP_MODE) and extra_specs.get(utils.REP_MODE) == utils.REP_SYNC): return self.rest.delete_rdf_pair(array, device_id, rdf_group) @@ -504,8 +521,13 @@ class VMAXProvision(object): action = "Failing back" LOG.info("%(action)s rdf pair: source device: %(src)s ", {'action': action, 'src': device_id}) - self.rest.modify_rdf_device_pair( - array, device_id, rdf_group, extra_specs, split=False) + + @coordination.synchronized('emc-rg-{rdfg_no}') + def _failover_volume(rdfg_no): + self.rest.modify_rdf_device_pair( + array, device_id, rdfg_no, extra_specs) + + _failover_volume(rdf_group) def get_or_create_volume_group(self, array, group, extra_specs): """Get or create a volume group. @@ -657,7 +679,7 @@ class VMAXProvision(object): return rc def enable_group_replication(self, array, storagegroup_name, - rdf_group_num, extra_specs): + rdf_group_num, extra_specs, establish=False): """Resume rdf replication on a storage group. Replication is enabled by default. This allows resuming @@ -666,8 +688,9 @@ class VMAXProvision(object): :param storagegroup_name: the storagegroup name :param rdf_group_num: the rdf group number :param extra_specs: the extra specifications + :param establish: flag to indicate 'establish' instead of 'resume' """ - action = "Resume" + action = "Establish" if establish is True else "Resume" self.rest.modify_storagegroup_rdf( array, storagegroup_name, rdf_group_num, action, extra_specs) diff --git a/cinder/volume/drivers/dell_emc/vmax/rest.py b/cinder/volume/drivers/dell_emc/vmax/rest.py index 88294f266e7..a0a9e832ec4 100644 --- a/cinder/volume/drivers/dell_emc/vmax/rest.py +++ b/cinder/volume/drivers/dell_emc/vmax/rest.py @@ -324,7 +324,8 @@ class VMAXRest(object): return resource_object def get_resource(self, array, category, resource_type, - resource_name=None, params=None, private=''): + resource_name=None, params=None, private='', + version=U4V_VERSION): """Get resource details from array. :param array: the array serial number @@ -333,10 +334,11 @@ class VMAXRest(object): :param resource_name: the name of a specific resource :param params: query parameters :param private: empty string or '/private' if private url + :param version: None or specific version number if required :returns: resource object -- dict or None """ target_uri = self._build_uri(array, category, resource_type, - resource_name, private) + resource_name, private, version=version) return self._get_request(target_uri, resource_type, params) def create_resource(self, array, category, resource_type, payload, @@ -495,20 +497,6 @@ class VMAXRest(object): array, SLOPROVISIONING, 'storagegroup', resource_name=storage_group_name) - def get_storage_group_list(self, array, params=None): - """"Return a list of storage groups. - - :param array: the array serial number - :param params: optional filter parameters - :returns: storage group list - """ - sg_list = [] - sg_details = self.get_resource(array, SLOPROVISIONING, - 'storagegroup', params=params) - if sg_details: - sg_list = sg_details['storageGroupId'] - return sg_list - def get_num_vols_in_sg(self, array, storage_group_name): """Get the number of volumes in a storage group. @@ -1271,7 +1259,7 @@ class VMAXRest(object): resource_name=initiator_group, params=params) def get_initiator(self, array, initiator_id): - """Retrieve initaitor details from the array. + """Retrieve initiator details from the array. :param array: the array serial number :param initiator_id: the initiator id @@ -1282,14 +1270,15 @@ class VMAXRest(object): resource_name=initiator_id) def get_initiator_list(self, array, params=None): - """Retrieve initaitor list from the array. + """Retrieve initiator list from the array. :param array: the array serial number :param params: dict of optional params :returns: list of initiators """ - init_dict = self.get_resource( - array, SLOPROVISIONING, 'initiator', params=params) + version = '90' if self.is_next_gen_array(array) else U4V_VERSION + init_dict = self.get_resource(array, SLOPROVISIONING, 'initiator', + params=params, version=version) try: init_list = init_dict['initiatorId'] except KeyError: @@ -1953,8 +1942,9 @@ class VMAXRest(object): :param extra_specs: the extra specs :returns: rdf_dict """ - rep_mode = (extra_specs[utils.REP_MODE] - if extra_specs.get(utils.REP_MODE) else utils.REP_SYNC) + rep_mode = extra_specs[utils.REP_MODE] + if rep_mode == utils.REP_METRO: + rep_mode = 'Active' payload = ({"deviceNameListSource": [{"name": device_id}], "deviceNameListTarget": [{"name": target_device}], "replicationMode": rep_mode, @@ -1963,6 +1953,9 @@ class VMAXRest(object): if rep_mode == utils.REP_ASYNC: payload_update = self._get_async_payload_info(array, rdf_group_no) payload.update(payload_update) + elif rep_mode == 'Active': + payload = self.get_metro_payload_info( + array, payload, rdf_group_no, extra_specs) resource_type = ("rdf_group/%(rdf_num)s/volume" % {'rdf_num': rdf_group_no}) status_code, job = self.create_resource(array, REPLICATION, @@ -1988,17 +1981,40 @@ class VMAXRest(object): payload_update = {'consExempt': 'true'} return payload_update - @coordination.synchronized('emc-rg-{rdf_group}') + def get_metro_payload_info(self, array, payload, + rdf_group_no, extra_specs): + """Get the payload details for a metro active create pair. + + :param array: the array serial number + :param payload: the payload + :param rdf_group_no: the rdf group number + :param extra_specs: the replication configuration + :return: updated payload + """ + num_vols = 0 + rdfg_details = self.get_rdf_group(array, rdf_group_no) + if rdfg_details is not None and rdfg_details.get('numDevices'): + num_vols = int(rdfg_details['numDevices']) + if num_vols == 0: + # First volume - set bias if required + if (extra_specs.get(utils.METROBIAS) + and extra_specs[utils.METROBIAS] is True): + payload.update({'metroBias': 'true'}) + else: + # Need to format subsequent volumes + payload['format'] = 'true' + payload.pop('establish') + payload['rdfType'] = 'NA' + return payload + def modify_rdf_device_pair( - self, array, device_id, rdf_group, extra_specs, - split=False, suspend=False): + self, array, device_id, rdf_group, extra_specs, suspend=False): """Modify an rdf device pair. :param array: the array serial number :param device_id: the device id :param rdf_group: the rdf group :param extra_specs: the extra specs - :param split: flag to indicate "split" action :param suspend: flag to indicate "suspend" action """ common_opts = {"force": 'false', @@ -2006,14 +2022,11 @@ class VMAXRest(object): "star": 'false', "hop2": 'false', "bypass": 'false'} - if split: - common_opts.update({"immediate": 'false'}) - payload = {"action": "Split", - "executionOption": "ASYNCHRONOUS", - "split": common_opts} - - elif suspend: - common_opts.update({"immediate": 'false', "consExempt": 'true'}) + if suspend: + if (extra_specs.get(utils.REP_MODE) + and extra_specs[utils.REP_MODE] == utils.REP_ASYNC): + common_opts.update({"immediate": 'false', + "consExempt": 'true'}) payload = {"action": "Suspend", "executionOption": "ASYNCHRONOUS", "suspend": common_opts} @@ -2034,7 +2047,6 @@ class VMAXRest(object): self.wait_for_job('Modify device pair', sc, job, extra_specs) - @coordination.synchronized('emc-rg-{rdf_group}') def delete_rdf_pair(self, array, device_id, rdf_group): """Delete an rdf pair. @@ -2199,7 +2211,10 @@ class VMAXRest(object): elif (action.lower() in ["split", "failover", "suspend"] and state.lower() in [utils.RDF_SYNC_STATE, utils.RDF_SYNCINPROG_STATE, - utils.RDF_CONSISTENT_STATE]): + utils.RDF_CONSISTENT_STATE, + utils.RDF_ACTIVE, + utils.RDF_ACTIVEACTIVE, + utils.RDF_ACTIVEBIAS]): mod_rqd = True break return mod_rqd @@ -2219,6 +2234,14 @@ class VMAXRest(object): rdf_group_num, action) if mod_reqd: payload = {"executionOption": "ASYNCHRONOUS", "action": action} + if action.lower() == 'suspend': + payload['suspend'] = {"force": "true"} + elif action.lower() == 'establish': + metro_bias = ( + True if extra_specs.get(utils.METROBIAS) + and extra_specs[utils.METROBIAS] is True else False) + payload['establish'] = {"metroBias": metro_bias, + "full": 'false'} resource_name = ('%(sg_name)s/rdf_group/%(rdf_num)s' % {'sg_name': storagegroup_name, 'rdf_num': rdf_group_num}) diff --git a/cinder/volume/drivers/dell_emc/vmax/utils.py b/cinder/volume/drivers/dell_emc/vmax/utils.py index 9c4db0f0aec..1fb6fcf5075 100644 --- a/cinder/volume/drivers/dell_emc/vmax/utils.py +++ b/cinder/volume/drivers/dell_emc/vmax/utils.py @@ -60,12 +60,17 @@ IS_RE = 'replication_enabled' DISABLECOMPRESSION = 'storagetype:disablecompression' REP_SYNC = 'Synchronous' REP_ASYNC = 'Asynchronous' +REP_METRO = 'Metro' REP_MODE = 'rep_mode' RDF_SYNC_STATE = 'synchronized' RDF_SYNCINPROG_STATE = 'syncinprog' RDF_CONSISTENT_STATE = 'consistent' RDF_SUSPENDED_STATE = 'suspended' RDF_FAILEDOVER_STATE = 'failed over' +RDF_ACTIVE = 'active' +RDF_ACTIVEACTIVE = 'activeactive' +RDF_ACTIVEBIAS = 'activebias' +METROBIAS = 'metro_bias' # Cinder.conf vmax configuration VMAX_SERVER_IP = 'san_ip' @@ -511,6 +516,18 @@ class VMAXUtils(object): rep_mode = target.get('mode', '') if rep_mode.lower() in ['async', 'asynchronous']: rep_config['mode'] = REP_ASYNC + elif rep_mode.lower() == 'metro': + rep_config['mode'] = REP_METRO + metro_bias = target.get('metro_use_bias', 'false') + if strutils.bool_from_string(metro_bias): + rep_config[METROBIAS] = True + else: + rep_config[METROBIAS] = False + allow_delete_metro = target.get('allow_delete_metro', 'false') + if strutils.bool_from_string(allow_delete_metro): + rep_config['allow_delete_metro'] = True + else: + rep_config['allow_delete_metro'] = False else: rep_config['mode'] = REP_SYNC @@ -749,12 +766,17 @@ class VMAXUtils(object): """Get the replication prefix. Replication prefix for storage group naming is based on whether it is - synchronous or asynchronous replication mode. + synchronous, asynchronous, or metro replication mode. :param rep_mode: flag to indicate if replication is async :return: prefix """ - prefix = "-RE" if rep_mode == REP_SYNC else "-RA" + if rep_mode == REP_ASYNC: + prefix = "-RA" + elif rep_mode == REP_METRO: + prefix = "-RM" + else: + prefix = "-RE" return prefix @staticmethod @@ -764,7 +786,33 @@ class VMAXUtils(object): :param rep_config: the replication configuration :return: group name """ - rdf_group_name = rep_config['rdf_group_label'] - async_grp_name = "OS-%(rdf)s-async-rdf-sg" % {'rdf': rdf_group_name} - LOG.debug("The async rdf managed group name is %s", async_grp_name) + async_grp_name = ("OS-%(rdf)s-%(mode)s-rdf-sg" + % {'rdf': rep_config['rdf_group_label'], + 'mode': rep_config['mode']}) + LOG.debug("The async/ metro rdf managed group name is %(name)s", + {'name': async_grp_name}) return async_grp_name + + def is_metro_device(self, rep_config, extra_specs): + """Determine if a volume is a Metro enabled device. + + :param rep_config: the replication configuration + :param extra_specs: the extra specifications + :return: bool + """ + is_metro = (True if self.is_replication_enabled(extra_specs) + and rep_config is not None + and rep_config['mode'] == REP_METRO else False) + return is_metro + + def does_vol_need_rdf_management_group(self, extra_specs): + """Determine if a volume is a Metro or Async. + + :param extra_specs: the extra specifications + :return: bool + """ + if (self.is_replication_enabled(extra_specs) and + extra_specs.get(REP_MODE, None) in + [REP_ASYNC, REP_METRO]): + return True + return False diff --git a/releasenotes/notes/vmax-replication-enhancements2-0ba03224cfca9959.yaml b/releasenotes/notes/vmax-replication-enhancements2-0ba03224cfca9959.yaml new file mode 100644 index 00000000000..f94d006e847 --- /dev/null +++ b/releasenotes/notes/vmax-replication-enhancements2-0ba03224cfca9959.yaml @@ -0,0 +1,4 @@ +--- +features: + - Support for VMAX SRDF/Metro on VMAX cinder driver. +