From 5a8f26eb62ac7130dec476db8661b96ed9c96715 Mon Sep 17 00:00:00 2001 From: Tina Date: Fri, 11 Nov 2016 02:50:43 +0000 Subject: [PATCH] Adds Unity Cinder Driver This driver supports both FC and iSCSI protocols and supports below operations: - Create / Delete volume - Extend volume - Attach / Detach volume - Create / Delete snapshot - Copy Image to Volume - Copy Volume to Image - Clone volume - Create volume from snapshot - Manage / Unmanage volume DocImpact Co-Authored-By: Cedric Zhuang Co-Authored-By: Ryan Liang Implements: blueprint emc-unity-driver Change-Id: I9573a9704342d77e6e5ce5746b8f29c6246af527 --- cinder/opts.py | 3 + .../volume/drivers/dell_emc/unity/__init__.py | 0 .../drivers/dell_emc/unity/fake_exception.py | 70 +++ .../drivers/dell_emc/unity/test_adapter.py | 516 +++++++++++++++++ .../drivers/dell_emc/unity/test_client.py | 433 +++++++++++++++ .../drivers/dell_emc/unity/test_driver.py | 234 ++++++++ .../drivers/dell_emc/unity/test_utils.py | 254 +++++++++ .../volume/drivers/dell_emc/unity/__init__.py | 18 + .../volume/drivers/dell_emc/unity/adapter.py | 523 ++++++++++++++++++ .../volume/drivers/dell_emc/unity/client.py | 279 ++++++++++ .../volume/drivers/dell_emc/unity/driver.py | 216 ++++++++ cinder/volume/drivers/dell_emc/unity/utils.py | 263 +++++++++ ...ell-emc-unity-driver-72cb901467b23b22.yaml | 3 + 13 files changed, 2812 insertions(+) create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/unity/__init__.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/unity/test_utils.py create mode 100644 cinder/volume/drivers/dell_emc/unity/__init__.py create mode 100644 cinder/volume/drivers/dell_emc/unity/adapter.py create mode 100644 cinder/volume/drivers/dell_emc/unity/client.py create mode 100644 cinder/volume/drivers/dell_emc/unity/driver.py create mode 100644 cinder/volume/drivers/dell_emc/unity/utils.py create mode 100644 releasenotes/notes/dell-emc-unity-driver-72cb901467b23b22.yaml diff --git a/cinder/opts.py b/cinder/opts.py index 6fa4f172327..c63f924460e 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -74,6 +74,8 @@ from cinder.volume.drivers.coprhd import scaleio as \ from cinder.volume.drivers import datera as cinder_volume_drivers_datera from cinder.volume.drivers.dell import dell_storagecenter_common as \ cinder_volume_drivers_dell_dellstoragecentercommon +from cinder.volume.drivers.dell_emc.unity import driver as \ + cinder_volume_drivers_dell_emc_unity_driver from cinder.volume.drivers.disco import disco as \ cinder_volume_drivers_disco_disco from cinder.volume.drivers.dothill import dothill_common as \ @@ -264,6 +266,7 @@ def list_opts(): cinder_volume_drivers_datera.d_opts, cinder_volume_drivers_dell_dellstoragecentercommon. common_opts, + cinder_volume_drivers_dell_emc_unity_driver.UNITY_OPTS, cinder_volume_drivers_disco_disco.disco_opts, cinder_volume_drivers_dothill_dothillcommon.common_opts, cinder_volume_drivers_dothill_dothillcommon.iscsi_opts, diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/__init__.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py new file mode 100644 index 00000000000..dec754b0fca --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/fake_exception.py @@ -0,0 +1,70 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class StoropsException(Exception): + message = 'Storops Error.' + + +class UnityLunNameInUseError(StoropsException): + pass + + +class UnityResourceNotFoundError(StoropsException): + pass + + +class UnitySnapNameInUseError(StoropsException): + pass + + +class UnityDeleteAttachedSnapError(StoropsException): + pass + + +class UnityResourceAlreadyAttachedError(StoropsException): + pass + + +class UnityPolicyNameInUseError(StoropsException): + pass + + +class UnityNothingToModifyError(StoropsException): + pass + + +class ExtendLunError(Exception): + pass + + +class DetachIsCalled(Exception): + pass + + +class LunDeleteIsCalled(Exception): + pass + + +class SnapDeleteIsCalled(Exception): + pass + + +class UnexpectedLunDeletion(Exception): + pass + + +class AdapterSetupError(Exception): + pass diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py new file mode 100644 index 00000000000..1e787b0b7f1 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_adapter.py @@ -0,0 +1,516 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import functools +import unittest + +import mock + +from cinder import exception +from cinder.tests.unit.volume.drivers.dell_emc.unity \ + import fake_exception as ex +from cinder.tests.unit.volume.drivers.dell_emc.unity import test_client +from cinder.volume.drivers.dell_emc.unity import adapter + + +######################## +# +# Start of Mocks +# +######################## +class MockConfig(object): + def __init__(self): + self.unity_storage_pool_names = ['pool1', 'pool2'] + self.reserved_percentage = 5 + self.max_over_subscription_ratio = 300 + self.volume_backend_name = 'backend' + self.san_ip = '1.2.3.4' + self.san_login = 'user' + self.san_password = 'pass' + self.driver_ssl_cert_verify = False + self.driver_ssl_cert_path = None + + def safe_get(self, name): + return getattr(self, name) + + +class MockConnector(object): + @staticmethod + def disconnect_volume(data, device): + pass + + +class MockDriver(object): + def __init__(self): + self.configuration = mock.Mock(volume_dd_blocksize='1M') + + @staticmethod + def _connect_device(conn): + return {'connector': MockConnector(), + 'device': {'path': 'dev'}, + 'conn': {'data': {}}} + + +class MockClient(object): + @staticmethod + def get_pools(): + return test_client.MockResourceList(['pool0', 'pool1']) + + @staticmethod + def create_lun(name, size, pool, description=None, io_limit_policy=None): + return test_client.MockResource(_id='lun_3') + + @staticmethod + def get_lun(name=None, lun_id=None): + if lun_id is None: + lun_id = 'lun_4' + if name == 'not_exists': + ret = test_client.MockResource(name=lun_id) + ret.existed = False + else: + ret = test_client.MockResource(_id=lun_id) + return ret + + @staticmethod + def delete_lun(lun_id): + if lun_id != 'lun_4': + raise ex.UnexpectedLunDeletion() + + @staticmethod + def get_serial(): + return 'CLIENT_SERIAL' + + @staticmethod + def create_snap(src_lun_id, name=None): + return test_client.MockResource(name=name, _id=src_lun_id) + + @staticmethod + def get_snap(name=None): + snap = test_client.MockResource(name=name) + if name is not None: + ret = snap + else: + ret = [snap] + return ret + + @staticmethod + def delete_snap(snap): + if snap.name in ('abc-def_snap',): + raise ex.SnapDeleteIsCalled() + + @staticmethod + def create_host(name, uids): + return test_client.MockResource(name=name) + + @staticmethod + def get_host(name): + return test_client.MockResource(name=name) + + @staticmethod + def attach(host, lun_or_snap): + return 10 + + @staticmethod + def detach(host, lun_or_snap): + error_ids = ['lun_43', 'snap_0'] + if host.name == 'host1' and lun_or_snap.get_id() in error_ids: + raise ex.DetachIsCalled() + + @staticmethod + def get_iscsi_target_info(): + return [{'portal': '1.2.3.4:1234', 'iqn': 'iqn.1-1.com.e:c.a.a0'}, + {'portal': '1.2.3.5:1234', 'iqn': 'iqn.1-1.com.e:c.a.a1'}] + + @staticmethod + def get_fc_target_info(host=None, logged_in_only=False): + if host and host.name == 'no_target': + ret = [] + else: + ret = ['8899AABBCCDDEEFF', '8899AABBCCDDFFEE'] + return ret + + @staticmethod + def create_lookup_service(): + return {} + + @staticmethod + def get_io_limit_policy(specs): + return None + + @staticmethod + def extend_lun(lun_id, size_gib): + if size_gib <= 0: + raise ex.ExtendLunError + + +class MockLookupService(object): + @staticmethod + def get_device_mapping_from_network(initiator_wwns, target_wwns): + return { + 'san_1': { + 'initiator_port_wwn_list': + ('200000051e55a100', '200000051e55a121'), + 'target_port_wwn_list': + ('100000051e55a100', '100000051e55a121') + } + } + + +def mock_adapter(driver_clz): + ret = driver_clz() + ret._client = MockClient() + ret.do_setup(MockDriver(), MockConfig()) + ret.lookup_service = MockLookupService() + return ret + + +def get_backend_qos_specs(volume): + return None + + +def get_connector_properties(): + return {'host': 'host1', 'wwpns': 'abcdefg'} + + +def copy_volume(from_path, to_path, size_in_m, block_size, sparse=True): + pass + + +def get_lun_pl(name): + return 'id^%s|system^CLIENT_SERIAL|type^lun|version^None' % name + + +def patch_for_unity_adapter(func): + @functools.wraps(func) + @mock.patch('cinder.volume.drivers.dell_emc.unity.utils.' + 'get_backend_qos_specs', + new=get_backend_qos_specs) + @mock.patch('cinder.utils.brick_get_connector_properties', + new=get_connector_properties) + @mock.patch('cinder.volume.utils.copy_volume', new=copy_volume) + def func_wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return func_wrapper + + +######################## +# +# Start of Tests +# +######################## +class CommonAdapterTest(unittest.TestCase): + def setUp(self): + self.adapter = mock_adapter(adapter.CommonAdapter) + + def test_get_managed_pools(self): + ret = self.adapter.get_managed_pools() + self.assertIn('pool1', ret) + self.assertNotIn('pool0', ret) + self.assertNotIn('pool2', ret) + + @patch_for_unity_adapter + def test_create_volume(self): + volume = mock.Mock(size=5, host='unity#pool1') + ret = self.adapter.create_volume(volume) + expected = get_lun_pl('lun_3') + self.assertEqual(expected, ret['provider_location']) + + def test_create_snapshot(self): + volume = mock.Mock(provider_location='id^lun_43') + snap = mock.Mock(volume=volume) + snap.name = 'abc-def_snap' + result = self.adapter.create_snapshot(snap) + self.assertEqual('abc-def_snap', result.name) + self.assertEqual('lun_43', result.get_id()) + + def test_delete_snap(self): + def f(): + snap = mock.Mock() + snap.name = 'abc-def_snap' + + self.adapter.delete_snapshot(snap) + + self.assertRaises(ex.SnapDeleteIsCalled, f) + + def test_get_lun_id_has_location(self): + volume = mock.Mock(provider_location='id^lun_43') + self.assertEqual('lun_43', self.adapter.get_lun_id(volume)) + + def test_get_lun_id_no_location(self): + volume = mock.Mock(provider_location=None) + self.assertEqual('lun_4', self.adapter.get_lun_id(volume)) + + def test_delete_volume(self): + volume = mock.Mock(provider_location='id^lun_4') + self.adapter.delete_volume(volume) + + def test_get_pool_stats(self): + stats_list = self.adapter.get_pools_stats() + self.assertEqual(1, len(stats_list)) + + stats = stats_list[0] + self.assertEqual('pool1', stats['pool_name']) + self.assertEqual(5, stats['total_capacity_gb']) + self.assertEqual('pool1|CLIENT_SERIAL', stats['location_info']) + self.assertEqual(6, stats['provisioned_capacity_gb']) + self.assertEqual(2, stats['free_capacity_gb']) + self.assertEqual(300, stats['max_over_subscription_ratio']) + self.assertEqual(5, stats['reserved_percentage']) + self.assertFalse(stats['thick_provisioning_support']) + self.assertTrue(stats['thin_provisioning_support']) + + def test_update_volume_stats(self): + stats = self.adapter.update_volume_stats() + self.assertEqual('backend', stats['volume_backend_name']) + self.assertEqual('unknown', stats['storage_protocol']) + self.assertTrue(stats['thin_provisioning_support']) + self.assertFalse(stats['thick_provisioning_support']) + self.assertEqual(1, len(stats['pools'])) + + def test_serial_number(self): + self.assertEqual('CLIENT_SERIAL', self.adapter.serial_number) + + def test_do_setup(self): + self.assertEqual('1.2.3.4', self.adapter.ip) + self.assertEqual('user', self.adapter.username) + self.assertEqual('pass', self.adapter.password) + self.assertFalse(self.adapter.array_cert_verify) + self.assertIsNone(self.adapter.array_ca_cert_path) + + def test_verify_cert_false_path_none(self): + self.adapter.array_cert_verify = False + self.adapter.array_ca_cert_path = None + self.assertFalse(self.adapter.verify_cert) + + def test_verify_cert_false_path_not_none(self): + self.adapter.array_cert_verify = False + self.adapter.array_ca_cert_path = '/tmp/array_ca.crt' + self.assertFalse(self.adapter.verify_cert) + + def test_verify_cert_true_path_none(self): + self.adapter.array_cert_verify = True + self.adapter.array_ca_cert_path = None + self.assertTrue(self.adapter.verify_cert) + + def test_verify_cert_true_path_valide(self): + self.adapter.array_cert_verify = True + self.adapter.array_ca_cert_path = '/tmp/array_ca.crt' + self.assertEqual(self.adapter.array_ca_cert_path, + self.adapter.verify_cert) + + def test_initialize_connection_common(self): + volume = mock.Mock(provider_location='id^lun_43', id='id_43') + connector = {'host': 'host1'} + data = self.adapter.initialize_connection(volume, connector)['data'] + self.assertTrue(data['target_discovered']) + self.assertEqual('id_43', data['volume_id']) + + def test_initialize_connection_for_resource(self): + snap = test_client.MockResource(_id='snap_1') + connector = {'host': 'host1'} + data = self.adapter._initialize_connection( + snap, connector, 'snap_1')['data'] + self.assertTrue(data['target_discovered']) + self.assertEqual('snap_1', data['volume_id']) + + def test_terminate_connection_common(self): + def f(): + volume = mock.Mock(provider_location='id^lun_43', id='id_43') + connector = {'host': 'host1'} + self.adapter.terminate_connection(volume, connector) + + self.assertRaises(ex.DetachIsCalled, f) + + def test_terminate_connection_snap(self): + def f(): + connector = {'host': 'host1'} + snap = test_client.MockResource(_id='snap_0') + self.adapter._terminate_connection(snap, connector) + + self.assertRaises(ex.DetachIsCalled, f) + + def test_manage_existing_by_name(self): + ref = {'source-id': 12} + volume = mock.Mock(name='lun1') + ret = self.adapter.manage_existing(volume, ref) + expected = get_lun_pl('12') + self.assertEqual(expected, ret['provider_location']) + + def test_manage_existing_by_id(self): + ref = {'source-name': 'lunx'} + volume = mock.Mock(name='lun1') + ret = self.adapter.manage_existing(volume, ref) + expected = get_lun_pl('lun_4') + self.assertEqual(expected, ret['provider_location']) + + def test_manage_existing_invalid_ref(self): + def f(): + ref = {} + volume = mock.Mock(name='lun1') + self.adapter.manage_existing(volume, ref) + + self.assertRaises(exception.ManageExistingInvalidReference, f) + + def test_manage_existing_lun_not_found(self): + def f(): + ref = {'source-name': 'not_exists'} + volume = mock.Mock(name='lun1') + self.adapter.manage_existing(volume, ref) + + self.assertRaises(exception.ManageExistingInvalidReference, f) + + @patch_for_unity_adapter + def test_manage_existing_get_size_invalid_backend(self): + def f(): + volume = mock.Mock(volume_type_id='thin', + host='host@backend#pool1') + ref = {'source-id': 12} + self.adapter.manage_existing_get_size(volume, ref) + + self.assertRaises(exception.ManageExistingInvalidReference, f) + + @patch_for_unity_adapter + def test_manage_existing_get_size_success(self): + volume = mock.Mock(volume_type_id='thin', host='host@backend#pool0') + ref = {'source-id': 12} + volume_size = self.adapter.manage_existing_get_size(volume, ref) + self.assertEqual(5, volume_size) + + @patch_for_unity_adapter + def test_create_volume_from_snapshot(self): + volume = mock.Mock(id='id_44', host='unity#pool1', + provider_location=get_lun_pl('12')) + snap = mock.Mock(name='snap_44') + ret = self.adapter.create_volume_from_snapshot(volume, snap) + self.assertEqual(get_lun_pl('lun_3'), ret['provider_location']) + + @patch_for_unity_adapter + def test_create_cloned_volume(self): + volume = mock.Mock(id='id_55', host='unity#pool1', size=3, + provider_location=get_lun_pl('lun55')) + src_vref = mock.Mock(id='id_66', name='LUN 66', + provider_location=get_lun_pl('lun66')) + ret = self.adapter.create_cloned_volume(volume, src_vref) + self.assertEqual(get_lun_pl('lun_3'), ret['provider_location']) + + def test_extend_volume_error(self): + def f(): + volume = mock.Mock(id='l56', provider_location=get_lun_pl('lun56')) + self.adapter.extend_volume(volume, -1) + + self.assertRaises(ex.ExtendLunError, f) + + def test_extend_volume_no_id(self): + def f(): + volume = mock.Mock(provider_location='type^lun') + self.adapter.extend_volume(volume, 5) + + self.assertRaises(exception.VolumeBackendAPIException, f) + + +class FCAdapterTest(unittest.TestCase): + def setUp(self): + self.adapter = mock_adapter(adapter.FCAdapter) + + def test_setup(self): + self.assertIsNotNone(self.adapter.lookup_service) + + def test_auto_zone_enabled(self): + self.assertTrue(self.adapter.auto_zone_enabled) + + def test_fc_protocol(self): + stats = mock_adapter(adapter.FCAdapter).update_volume_stats() + self.assertEqual('FC', stats['storage_protocol']) + + def test_get_connector_uids(self): + connector = {'host': 'fake_host', + 'wwnns': ['1111111111111111', + '2222222222222222'], + 'wwpns': ['3333333333333333', + '4444444444444444'] + } + expected = ['11:11:11:11:11:11:11:11:33:33:33:33:33:33:33:33', + '22:22:22:22:22:22:22:22:44:44:44:44:44:44:44:44'] + ret = self.adapter.get_connector_uids(connector) + self.assertListEqual(expected, ret) + + def test_get_connection_info_no_targets(self): + def f(): + host = test_client.MockResource('no_target') + self.adapter.get_connection_info(12, host, {}) + + self.assertRaises(exception.VolumeBackendAPIException, f) + + def test_get_connection_info_auto_zone_enabled(self): + host = test_client.MockResource('host1') + connector = {'wwpns': 'abcdefg'} + ret = self.adapter.get_connection_info(10, host, connector) + target_wwns = ['100000051e55a100', '100000051e55a121'] + self.assertListEqual(target_wwns, ret['target_wwn']) + init_target_map = { + '200000051e55a100': ('100000051e55a100', '100000051e55a121'), + '200000051e55a121': ('100000051e55a100', '100000051e55a121')} + self.assertDictEqual(init_target_map, ret['initiator_target_map']) + self.assertEqual(10, ret['target_lun']) + + def test_get_connection_info_auto_zone_disabled(self): + self.adapter.lookup_service = None + host = test_client.MockResource('host1') + connector = {'wwpns': 'abcdefg'} + ret = self.adapter.get_connection_info(10, host, connector) + self.assertEqual(10, ret['target_lun']) + wwns = ['8899AABBCCDDEEFF', '8899AABBCCDDFFEE'] + self.assertListEqual(wwns, ret['target_wwn']) + + def test_terminate_connection_auto_zone_enabled(self): + connector = {'host': 'host1', 'wwpns': 'abcdefg'} + volume = mock.Mock(provider_location='id^lun_41', id='id_41') + ret = self.adapter.terminate_connection(volume, connector) + self.assertEqual('fibre_channel', ret['driver_volume_type']) + data = ret['data'] + target_map = { + '200000051e55a100': ('100000051e55a100', '100000051e55a121'), + '200000051e55a121': ('100000051e55a100', '100000051e55a121')} + self.assertDictEqual(target_map, data['initiator_target_map']) + target_wwn = ['100000051e55a100', '100000051e55a121'] + self.assertListEqual(target_wwn, data['target_wwn']) + + +class ISCSIAdapterTest(unittest.TestCase): + def setUp(self): + self.adapter = mock_adapter(adapter.ISCSIAdapter) + + def test_iscsi_protocol(self): + stats = self.adapter.update_volume_stats() + self.assertEqual('iSCSI', stats['storage_protocol']) + + def test_get_connector_uids(self): + connector = {'host': 'fake_host', 'initiator': 'fake_iqn'} + ret = self.adapter.get_connector_uids(connector) + self.assertListEqual(['fake_iqn'], ret) + + def test_get_connection_info(self): + connector = {'host': 'fake_host', 'initiator': 'fake_iqn'} + hlu = 10 + info = self.adapter.get_connection_info(hlu, None, connector) + target_iqns = ['iqn.1-1.com.e:c.a.a0', 'iqn.1-1.com.e:c.a.a1'] + target_portals = ['1.2.3.4:1234', '1.2.3.5:1234'] + self.assertListEqual(target_iqns, info['target_iqns']) + self.assertListEqual([hlu, hlu], info['target_luns']) + self.assertListEqual(target_portals, info['target_portals']) + self.assertEqual(hlu, info['target_lun']) + self.assertTrue(info['target_portal'] in target_portals) + self.assertTrue(info['target_iqn'] in target_iqns) diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py new file mode 100644 index 00000000000..a4c70007b32 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_client.py @@ -0,0 +1,433 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest + +from mock import mock +from oslo_utils import units + +from cinder.tests.unit.volume.drivers.dell_emc.unity \ + import fake_exception as ex +from cinder.volume.drivers.dell_emc.unity import client + + +######################## +# +# Start of Mocks +# +######################## + + +class MockResource(object): + def __init__(self, name=None, _id=None): + self.name = name + self._id = _id + self.existed = True + self.size_total = 5 * units.Gi + self.size_subscribed = 6 * units.Gi + self.size_free = 2 * units.Gi + self.is_auto_delete = None + self.initiator_id = [] + self.alu_hlu_map = {'already_attached': 99} + self.ip_address = None + self.is_logged_in = None + self.wwn = None + self.max_iops = None + self.max_kbps = None + self.pool_name = 'Pool0' + + def get_id(self): + return self._id + + def delete(self): + if self.get_id() in ['snap_2']: + raise ex.SnapDeleteIsCalled() + elif self.get_id() == 'not_found': + raise ex.UnityResourceNotFoundError() + elif self.get_id() == 'snap_in_use': + raise ex.UnityDeleteAttachedSnapError() + + @property + def pool(self): + return MockResource('pool0') + + @property + def iscsi_host_initiators(self): + iscsi_initiator = MockResource('iscsi_initiator') + iscsi_initiator.initiator_id = ['iqn.1-1.com.e:c.host.0', + 'iqn.1-1.com.e:c.host.1'] + return iscsi_initiator + + @property + def total_size_gb(self): + return self.size_total / units.Gi + + @total_size_gb.setter + def total_size_gb(self, value): + if value == self.total_size_gb: + raise ex.UnityNothingToModifyError() + else: + self.size_total = value * units.Gi + + def add_initiator(self, uid, force_create=None): + self.initiator_id.append(uid) + + def attach(self, lun_or_snap, skip_hlu_0=True): + if lun_or_snap.get_id() == 'already_attached': + raise ex.UnityResourceAlreadyAttachedError() + self.alu_hlu_map[lun_or_snap.get_id()] = len(self.alu_hlu_map) + return self.get_hlu(lun_or_snap) + + @staticmethod + def detach(lun_or_snap): + if lun_or_snap.name == 'detach_failure': + raise ex.DetachIsCalled() + + def get_hlu(self, lun): + return self.alu_hlu_map.get(lun.get_id(), None) + + @staticmethod + def create_lun(lun_name, size_gb, description=None, io_limit_policy=None): + if lun_name == 'in_use': + raise ex.UnityLunNameInUseError() + ret = MockResource(lun_name, 'lun_2') + if io_limit_policy is not None: + ret.max_iops = io_limit_policy.max_iops + ret.max_kbps = io_limit_policy.max_kbps + return ret + + @staticmethod + def create_snap(name, is_auto_delete=False): + if name == 'in_use': + raise ex.UnitySnapNameInUseError() + ret = MockResource(name) + ret.is_auto_delete = is_auto_delete + return ret + + @staticmethod + def update(data=None): + pass + + @property + def iscsi_node(self): + name = 'iqn.1-1.com.e:c.%s.0' % self.name + return MockResource(name) + + @property + def fc_host_initiators(self): + init0 = MockResource('fhi_0') + init0.initiator_id = '00:11:22:33:44:55:66:77:88:99:AA:BB:CC:CD:EE:FF' + init1 = MockResource('fhi_1') + init1.initiator_id = '00:11:22:33:44:55:66:77:88:99:AA:BB:BC:CD:EE:FF' + return MockResourceList.create(init0, init1) + + @property + def paths(self): + path0 = MockResource('%s_path_0' % self.name) + path0.is_logged_in = True + path1 = MockResource('%s_path_1' % self.name) + path1.is_logged_in = False + path2 = MockResource('%s_path_2' % self.name) + path2.is_logged_in = True + return [path0, path1] + + @property + def fc_port(self): + ret = MockResource() + ret.wwn = '00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF' + return ret + + @property + def host_luns(self): + return [] + + @property + def storage_resource(self): + return MockResource(_id='sr_%s' % self._id, + name='sr_%s' % self.name) + + def modify(self, name=None): + self.name = name + + +class MockResourceList(object): + def __init__(self, names): + self.resources = [MockResource(name) for name in names] + + @staticmethod + def create(*rsc_list): + ret = MockResourceList([]) + ret.resources = rsc_list + return ret + + @property + def name(self): + return map(lambda i: i.name, self.resources) + + def __iter__(self): + return self.resources.__iter__() + + def __len__(self): + return len(self.resources) + + def __getattr__(self, item): + return [getattr(i, item) for i in self.resources] + + +class MockSystem(object): + def __init__(self): + self.serial_number = 'SYSTEM_SERIAL' + + @staticmethod + def get_lun(_id=None, name=None): + if _id == 'not_found': + raise ex.UnityResourceNotFoundError() + return MockResource(name, _id) + + @staticmethod + def get_pool(): + return MockResourceList(['Pool 1', 'Pool 2']) + + @staticmethod + def get_snap(name): + if name == 'not_found': + raise ex.UnityResourceNotFoundError() + return MockResource(name) + + @staticmethod + def create_host(name): + return MockResource(name) + + @staticmethod + def get_host(name): + if name == 'not_found': + raise ex.UnityResourceNotFoundError() + return MockResource(name) + + @staticmethod + def get_iscsi_portal(): + portal0 = MockResource('p0') + portal0.ip_address = '1.1.1.1' + portal1 = MockResource('p1') + portal1.ip_address = '1.1.1.2' + return [portal0, portal1] + + @staticmethod + def get_fc_port(): + port0 = MockResource('fcp0') + port0.wwn = '00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF' + port1 = MockResource('fcp1') + port1.wwn = '00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:FF:EE' + return [port0, port1] + + @staticmethod + def create_io_limit_policy(name, max_iops=None, max_kbps=None): + if name == 'in_use': + raise ex.UnityPolicyNameInUseError() + ret = MockResource(name) + ret.max_iops = max_iops + ret.max_kbps = max_kbps + return ret + + @staticmethod + def get_io_limit_policy(name): + return MockResource(name=name) + + +@mock.patch.object(client, 'storops', new='True') +def get_client(): + ret = client.UnityClient('1.2.3.4', 'user', 'pass') + ret._system = MockSystem() + return ret + + +######################## +# +# Start of Tests +# +######################## +@mock.patch.object(client, 'storops_ex', new=ex) +class ClientTest(unittest.TestCase): + def setUp(self): + self.client = get_client() + + def test_get_serial(self): + self.assertEqual('SYSTEM_SERIAL', self.client.get_serial()) + + def test_create_lun_success(self): + name = 'LUN 3' + pool = MockResource('Pool 0') + lun = self.client.create_lun(name, 5, pool) + self.assertEqual(name, lun.name) + + def test_create_lun_name_in_use(self): + name = 'in_use' + pool = MockResource('Pool 0') + lun = self.client.create_lun(name, 6, pool) + self.assertEqual('in_use', lun.name) + + def test_create_lun_with_io_limit(self): + pool = MockResource('Pool 0') + limit = MockResource('limit') + limit.max_kbps = 100 + lun = self.client.create_lun('LUN 4', 6, pool, io_limit_policy=limit) + self.assertEqual(100, lun.max_kbps) + + def test_delete_lun_normal(self): + self.assertIsNone(self.client.delete_lun('lun3')) + + def test_delete_lun_not_found(self): + try: + self.client.delete_lun('not_found') + except ex.StoropsException: + self.fail('not found error should be dealt with silently.') + + def test_get_lun_with_id(self): + lun = self.client.get_lun('lun4') + self.assertEqual('lun4', lun.get_id()) + + def test_get_lun_with_name(self): + lun = self.client.get_lun(name='LUN 4') + self.assertEqual('LUN 4', lun.name) + + def test_get_lun_not_found(self): + ret = self.client.get_lun(lun_id='not_found') + self.assertIsNone(ret) + + def test_get_pools(self): + pools = self.client.get_pools() + self.assertEqual(2, len(pools)) + + def test_create_snap_normal(self): + snap = self.client.create_snap('lun_1', 'snap_1') + self.assertEqual('snap_1', snap.name) + + def test_create_snap_in_use(self): + snap = self.client.create_snap('lun_1', 'in_use') + self.assertEqual('in_use', snap.name) + + def test_delete_snap_error(self): + def f(): + snap = MockResource(_id='snap_2') + self.client.delete_snap(snap) + + self.assertRaises(ex.SnapDeleteIsCalled, f) + + def test_delete_snap_not_found(self): + try: + snap = MockResource(_id='not_found') + self.client.delete_snap(snap) + except ex.StoropsException: + self.fail('snap not found should not raise exception.') + + def test_delete_snap_none(self): + try: + ret = self.client.delete_snap(None) + self.assertIsNone(ret) + except ex.StoropsException: + self.fail('delete none should not raise exception.') + + def test_delete_snap_in_use(self): + def f(): + snap = MockResource(_id='snap_in_use') + self.client.delete_snap(snap) + + self.assertRaises(ex.UnityDeleteAttachedSnapError, f) + + def test_get_snap_found(self): + snap = self.client.get_snap('snap_2') + self.assertEqual('snap_2', snap.name) + + def test_get_snap_not_found(self): + ret = self.client.get_snap('not_found') + self.assertIsNone(ret) + + def test_create_host_found(self): + iqns = ['iqn.1-1.com.e:c.a.a0'] + host = self.client.create_host('host1', iqns) + + self.assertEqual('host1', host.name) + self.assertLessEqual(['iqn.1-1.com.e:c.a.a0'], host.initiator_id) + + def test_create_host_not_found(self): + host = self.client.create_host('not_found', []) + self.assertEqual('not_found', host.name) + + def test_attach_lun(self): + lun = MockResource(_id='lun1', name='l1') + host = MockResource('host1') + self.assertEqual(1, self.client.attach(host, lun)) + + def test_attach_already_attached(self): + lun = MockResource(_id='already_attached') + host = MockResource('host1') + hlu = self.client.attach(host, lun) + self.assertEqual(99, hlu) + + def test_detach_lun(self): + def f(): + lun = MockResource('detach_failure') + host = MockResource('host1') + self.client.detach(host, lun) + + self.assertRaises(ex.DetachIsCalled, f) + + def test_get_host(self): + self.assertEqual('host2', self.client.get_host('host2').name) + + def test_get_iscsi_target_info(self): + ret = self.client.get_iscsi_target_info() + expected = [{'iqn': 'iqn.1-1.com.e:c.p0.0', 'portal': '1.1.1.1:3260'}, + {'iqn': 'iqn.1-1.com.e:c.p1.0', 'portal': '1.1.1.2:3260'}] + self.assertListEqual(expected, ret) + + def test_get_fc_target_info_without_host(self): + ret = self.client.get_fc_target_info() + self.assertListEqual(['8899AABBCCDDEEFF', '8899AABBCCDDFFEE'], ret) + + def test_get_fc_target_info_with_host(self): + host = MockResource('host0') + ret = self.client.get_fc_target_info(host, True) + self.assertListEqual(['8899AABBCCDDEEFF', '8899AABBCCDDEEFF'], ret) + + def test_get_io_limit_policy_none(self): + ret = self.client.get_io_limit_policy(None) + self.assertIsNone(ret) + + def test_get_io_limit_policy_create_new(self): + specs = {'maxBWS': 2, 'id': 'max_2_mbps', 'maxIOPS': None} + limit = self.client.get_io_limit_policy(specs) + self.assertEqual('max_2_mbps', limit.name) + self.assertEqual(2, limit.max_kbps) + + def test_create_io_limit_policy_success(self): + limit = self.client.create_io_limit_policy('3kiops', max_iops=3000) + self.assertEqual('3kiops', limit.name) + self.assertEqual(3000, limit.max_iops) + + def test_create_io_limit_policy_in_use(self): + limit = self.client.create_io_limit_policy('in_use', max_iops=100) + self.assertEqual('in_use', limit.name) + + def test_expand_lun_success(self): + lun = self.client.extend_lun('ev_3', 6) + self.assertEqual(6, lun.total_size_gb) + + def test_expand_lun_nothing_to_modify(self): + lun = self.client.extend_lun('ev_4', 5) + self.assertEqual(5, lun.total_size_gb) + + def test_get_pool_name(self): + self.assertEqual('Pool0', self.client.get_pool_name('lun_0')) diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py new file mode 100644 index 00000000000..0f7cb261d77 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_driver.py @@ -0,0 +1,234 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest + +import mock + +from cinder.tests.unit.volume.drivers.dell_emc.unity \ + import fake_exception as ex +from cinder.volume import configuration as conf +from cinder.volume.drivers.dell_emc.unity import driver + + +######################## +# +# Start of Mocks +# +######################## + +class MockAdapter(object): + def do_setup(self, driver_object, configuration): + raise ex.AdapterSetupError() + + @staticmethod + def create_volume(volume): + return volume + + @staticmethod + def create_volume_from_snapshot(volume, snapshot): + return volume + + @staticmethod + def create_cloned_volume(volume, src_vref): + return volume + + @staticmethod + def extend_volume(volume, new_size): + volume.size = new_size + + @staticmethod + def delete_volume(volume): + volume.exists = False + + @staticmethod + def create_snapshot(snapshot): + snapshot.exists = True + + @staticmethod + def delete_snapshot(snapshot): + snapshot.exists = False + + @staticmethod + def initialize_connection(volume, connector): + return {'volume': volume, 'connector': connector} + + @staticmethod + def terminate_connection(volume, connector): + return {'volume': volume, 'connector': connector} + + @staticmethod + def update_volume_stats(): + return {'stats': 123} + + @staticmethod + def manage_existing(volume, existing_ref): + volume.managed = True + return volume + + @staticmethod + def manage_existing_get_size(volume, existing_ref): + volume.managed = True + volume.size = 7 + return volume + + @staticmethod + def get_pool_name(volume): + return 'pool_0' + + +######################## +# +# Start of Tests +# +######################## + +class UnityDriverTest(unittest.TestCase): + @staticmethod + def get_volume(): + return mock.Mock(provider_location='id^lun_43', id='id_43') + + @classmethod + def get_snapshot(cls): + return mock.Mock(volume=cls.get_volume()) + + @staticmethod + def get_context(): + return None + + @staticmethod + def get_connector(): + return {'host': 'host1'} + + def setUp(self): + self.config = conf.Configuration(None) + self.driver = driver.UnityDriver(configuration=self.config) + self.driver.adapter = MockAdapter() + + def test_default_initialize(self): + config = conf.Configuration(None) + iscsi_driver = driver.UnityDriver(configuration=config) + self.assertIsNone(config.unity_storage_pool_names) + self.assertTrue(config.san_thin_provision) + self.assertEqual('', config.san_ip) + self.assertEqual('admin', config.san_login) + self.assertEqual('', config.san_password) + self.assertEqual('', config.san_private_key) + self.assertEqual('', config.san_clustername) + self.assertEqual(22, config.san_ssh_port) + self.assertEqual(False, config.san_is_local) + self.assertEqual(30, config.ssh_conn_timeout) + self.assertEqual(1, config.ssh_min_pool_conn) + self.assertEqual(5, config.ssh_max_pool_conn) + self.assertEqual('iSCSI', iscsi_driver.protocol) + + def test_fc_initialize(self): + config = conf.Configuration(None) + config.storage_protocol = 'fc' + fc_driver = driver.UnityDriver(configuration=config) + self.assertEqual('FC', fc_driver.protocol) + + def test_do_setup(self): + def f(): + self.driver.do_setup(None) + + self.assertRaises(ex.AdapterSetupError, f) + + def test_create_volume(self): + volume = self.get_volume() + self.assertEqual(volume, self.driver.create_volume(volume)) + + def test_create_volume_from_snapshot(self): + volume = self.get_volume() + snap = self.get_snapshot() + self.assertEqual( + volume, self.driver.create_volume_from_snapshot(volume, snap)) + + def test_create_cloned_volume(self): + volume = self.get_volume() + self.assertEqual( + volume, self.driver.create_cloned_volume(volume, None)) + + def test_extend_volume(self): + volume = self.get_volume() + self.driver.extend_volume(volume, 6) + self.assertEqual(6, volume.size) + + def test_delete_volume(self): + volume = self.get_volume() + self.driver.delete_volume(volume) + self.assertFalse(volume.exists) + + def test_create_snapshot(self): + snapshot = self.get_snapshot() + self.driver.create_snapshot(snapshot) + self.assertTrue(snapshot.exists) + + def test_delete_snapshot(self): + snapshot = self.get_snapshot() + self.driver.delete_snapshot(snapshot) + self.assertFalse(snapshot.exists) + + def test_ensure_export(self): + self.assertIsNone(self.driver.ensure_export( + self.get_context(), self.get_volume())) + + def test_create_export(self): + self.assertIsNone(self.driver.create_export( + self.get_context(), self.get_volume(), self.get_connector())) + + def test_remove_export(self): + self.assertIsNone(self.driver.remove_export( + self.get_context(), self.get_volume())) + + def test_check_for_export(self): + self.assertIsNone(self.driver.check_for_export( + self.get_context(), self.get_volume())) + + def test_initialize_connection(self): + volume = self.get_volume() + connector = self.get_connector() + conn_info = self.driver.initialize_connection(volume, connector) + self.assertEqual(volume, conn_info['volume']) + self.assertEqual(connector, conn_info['connector']) + + def test_terminate_connection(self): + volume = self.get_volume() + connector = self.get_connector() + conn_info = self.driver.terminate_connection(volume, connector) + self.assertEqual(volume, conn_info['volume']) + self.assertEqual(connector, conn_info['connector']) + + def test_update_volume_stats(self): + stats = self.driver.get_volume_stats(True) + self.assertEqual(123, stats['stats']) + self.assertEqual(self.driver.VERSION, stats['driver_version']) + self.assertEqual(self.driver.VENDOR, stats['vendor_name']) + + def test_manage_existing(self): + volume = self.driver.manage_existing(self.get_volume(), None) + self.assertTrue(volume.managed) + + def test_manage_existing_get_size(self): + volume = self.driver.manage_existing_get_size(self.get_volume(), None) + self.assertTrue(volume.managed) + self.assertEqual(7, volume.size) + + def test_get_pool(self): + self.assertEqual('pool_0', self.driver.get_pool(self.get_volume())) + + def test_unmanage(self): + ret = self.driver.unmanage(None) + self.assertIsNone(ret) diff --git a/cinder/tests/unit/volume/drivers/dell_emc/unity/test_utils.py b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_utils.py new file mode 100644 index 00000000000..abb10d7dd28 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/unity/test_utils.py @@ -0,0 +1,254 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import functools +import unittest + +import mock +from oslo_utils import units + +from cinder import exception +from cinder.volume.drivers.dell_emc.unity import utils + + +def get_volume_type_extra_specs(volume_type): + return {'provisioning:type': volume_type} + + +def get_volume_type_qos_specs(type_id): + if type_id == 'invalid_backend_qos_consumer': + ret = {'qos_specs': {'consumer': 'invalid'}} + elif type_id == 'both_none': + ret = {'qos_specs': {'consumer': 'back-end', 'specs': {}}} + elif type_id == 'max_1000_iops': + ret = { + 'qos_specs': { + 'id': 'max_1000_iops', + 'consumer': 'both', + 'specs': { + 'maxIOPS': 1000 + } + } + } + elif type_id == 'max_2_mbps': + ret = { + 'qos_specs': { + 'id': 'max_2_mbps', + 'consumer': 'back-end', + 'specs': { + 'maxBWS': 2 + } + } + } + else: + ret = None + return ret + + +def patch_volume_types(func): + @functools.wraps(func) + @mock.patch(target=('cinder.volume.volume_types' + '.get_volume_type_extra_specs'), + new=get_volume_type_extra_specs) + @mock.patch(target=('cinder.volume.volume_types' + '.get_volume_type_qos_specs'), + new=get_volume_type_qos_specs) + def func_wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return func_wrapper + + +class UnityUtilsTest(unittest.TestCase): + def test_validate_pool_names_filter(self): + all_pools = list('acd') + pool_names = utils.validate_pool_names(list('abc'), all_pools) + self.assertIn('a', pool_names) + self.assertIn('c', pool_names) + self.assertNotIn('b', pool_names) + self.assertNotIn('d', pool_names) + + def test_validate_pool_names_non_exists(self): + def f(): + all_pools = list('abc') + utils.validate_pool_names(list('efg'), all_pools) + + self.assertRaises(exception.VolumeBackendAPIException, f) + + def test_validate_pool_names_default(self): + all_pools = list('ab') + pool_names = utils.validate_pool_names([], all_pools) + self.assertEqual(2, len(pool_names)) + + pool_names = utils.validate_pool_names(None, all_pools) + self.assertEqual(2, len(pool_names)) + + def test_build_provider_location(self): + location = utils.build_provider_location('unity', 'thin', 'ev_1', '3') + expected = 'id^ev_1|system^unity|type^thin|version^3' + self.assertEqual(expected, location) + + def test_extract_provider_location_version(self): + location = 'id^ev_1|system^unity|type^thin|version^3' + self.assertEqual('3', + utils.extract_provider_location(location, 'version')) + + def test_extract_provider_location_type(self): + location = 'id^ev_1|system^unity|type^thin|version^3' + self.assertEqual('thin', + utils.extract_provider_location(location, 'type')) + + def test_extract_provider_location_system(self): + location = 'id^ev_1|system^unity|type^thin|version^3' + self.assertEqual('unity', + utils.extract_provider_location(location, 'system')) + + def test_extract_provider_location_id(self): + location = 'id^ev_1|system^unity|type^thin|version^3' + self.assertEqual('ev_1', + utils.extract_provider_location(location, 'id')) + + def test_extract_provider_location_not_found(self): + location = 'id^ev_1|system^unity|type^thin|version^3' + self.assertIsNone(utils.extract_provider_location(location, 'na')) + + def test_extract_provider_location_none(self): + self.assertIsNone(utils.extract_provider_location(None, 'abc')) + + def test_extract_iscsi_uids(self): + connector = {'host': 'fake_host', + 'initiator': 'fake_iqn'} + self.assertEqual(['fake_iqn'], + utils.extract_iscsi_uids(connector)) + + def test_extract_iscsi_uids_not_found(self): + connector = {'host': 'fake_host'} + self.assertRaises(exception.VolumeBackendAPIException, + utils.extract_iscsi_uids, + connector) + + def test_extract_fc_uids(self): + connector = {'host': 'fake_host', + 'wwnns': ['1111111111111111', + '2222222222222222'], + 'wwpns': ['3333333333333333', + '4444444444444444'] + } + self.assertEqual(['11:11:11:11:11:11:11:11:33:33:33:33:33:33:33:33', + '22:22:22:22:22:22:22:22:44:44:44:44:44:44:44:44', ], + utils.extract_fc_uids(connector)) + + def test_extract_fc_uids_not_found(self): + connector = {'host': 'fake_host'} + self.assertRaises(exception.VolumeBackendAPIException, + utils.extract_iscsi_uids, + connector) + + def test_byte_to_gib(self): + self.assertEqual(5, utils.byte_to_gib(5 * units.Gi)) + + def test_byte_to_mib(self): + self.assertEqual(5, utils.byte_to_mib(5 * units.Mi)) + + def test_gib_to_mib(self): + self.assertEqual(5 * units.Gi / units.Mi, utils.gib_to_mib(5)) + + def test_convert_ip_to_portal(self): + self.assertEqual('1.2.3.4:3260', utils.convert_ip_to_portal('1.2.3.4')) + + def test_convert_to_itor_tgt_map(self): + zone_mapping = { + 'san_1': { + 'initiator_port_wwn_list': + ('200000051e55a100', '200000051e55a121'), + 'target_port_wwn_list': + ('100000051e55a100', '100000051e55a121') + } + } + ret = utils.convert_to_itor_tgt_map(zone_mapping) + self.assertEqual(['100000051e55a100', '100000051e55a121'], ret[0]) + mapping = ret[1] + targets = ('100000051e55a100', '100000051e55a121') + self.assertEqual(targets, mapping['200000051e55a100']) + self.assertEqual(targets, mapping['200000051e55a121']) + + def test_get_pool_name(self): + volume = mock.Mock(host='host@backend#pool_name') + self.assertEqual('pool_name', utils.get_pool_name(volume)) + + def test_ignore_exception(self): + class IgnoredException(Exception): + pass + + def f(): + raise IgnoredException('any exception') + + try: + utils.ignore_exception(f) + except IgnoredException: + self.fail('should not raise any exception.') + + def test_assure_cleanup(self): + data = [0] + + def _enter(): + data[0] += 10 + return data[0] + + def _exit(x): + data[0] = x - 1 + + ctx = utils.assure_cleanup(_enter, _exit, True) + with ctx as r: + self.assertEqual(10, r) + + self.assertEqual(9, data[0]) + + def test_get_backend_qos_specs_type_none(self): + volume = mock.Mock(volume_type_id=None) + ret = utils.get_backend_qos_specs(volume) + self.assertIsNone(ret) + + @patch_volume_types + def test_get_backend_qos_specs_none(self): + volume = mock.Mock(volume_type_id='no_qos') + ret = utils.get_backend_qos_specs(volume) + self.assertIsNone(ret) + + @patch_volume_types + def test_get_backend_qos_invalid_consumer(self): + volume = mock.Mock(volume_type_id='invalid_backend_qos_consumer') + ret = utils.get_backend_qos_specs(volume) + self.assertIsNone(ret) + + @patch_volume_types + def test_get_backend_qos_both_none(self): + volume = mock.Mock(volume_type_id='both_none') + ret = utils.get_backend_qos_specs(volume) + self.assertIsNone(ret) + + @patch_volume_types + def test_get_backend_qos_iops(self): + volume = mock.Mock(volume_type_id='max_1000_iops') + ret = utils.get_backend_qos_specs(volume) + expected = {'maxBWS': None, 'id': 'max_1000_iops', 'maxIOPS': 1000} + self.assertEqual(expected, ret) + + @patch_volume_types + def test_get_backend_qos_mbps(self): + volume = mock.Mock(volume_type_id='max_2_mbps') + ret = utils.get_backend_qos_specs(volume) + expected = {'maxBWS': 2, 'id': 'max_2_mbps', 'maxIOPS': None} + self.assertEqual(expected, ret) diff --git a/cinder/volume/drivers/dell_emc/unity/__init__.py b/cinder/volume/drivers/dell_emc/unity/__init__.py new file mode 100644 index 00000000000..bc5c9f3efb5 --- /dev/null +++ b/cinder/volume/drivers/dell_emc/unity/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinder.volume.drivers.dell_emc.unity import driver + +Driver = driver.UnityDriver diff --git a/cinder/volume/drivers/dell_emc/unity/adapter.py b/cinder/volume/drivers/dell_emc/unity/adapter.py new file mode 100644 index 00000000000..6c03b2e908d --- /dev/null +++ b/cinder/volume/drivers/dell_emc/unity/adapter.py @@ -0,0 +1,523 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import contextlib +import functools +import random + +from oslo_log import log as logging +from oslo_utils import excutils + +from cinder import exception +from cinder import utils as cinder_utils +from cinder.i18n import _, _LE, _LI +from cinder.volume.drivers.dell_emc.unity import client +from cinder.volume.drivers.dell_emc.unity import utils +from cinder.volume import utils as vol_utils + +LOG = logging.getLogger(__name__) + +PROTOCOL_FC = 'FC' +PROTOCOL_ISCSI = 'iSCSI' + + +class CommonAdapter(object): + protocol = 'unknown' + driver_name = 'UnityAbstractDriver' + driver_volume_type = 'unknown' + + def __init__(self, version=None): + self.version = version + self.driver = None + self.configured_pool_names = None + self.reserved_percentage = None + self.max_over_subscription_ratio = None + self.volume_backend_name = None + self.ip = None + self.username = None + self.password = None + self.array_cert_verify = None + self.array_ca_cert_path = None + + self._serial_number = None + self.storage_pools_map = None + self._client = None + + def do_setup(self, driver, conf): + self.driver = driver + self.configured_pool_names = conf.unity_storage_pool_names + self.reserved_percentage = conf.reserved_percentage + self.max_over_subscription_ratio = conf.max_over_subscription_ratio + self.volume_backend_name = (conf.safe_get('volume_backend_name') or + self.driver_name) + self.ip = conf.san_ip + self.username = conf.san_login + self.password = conf.san_password + # Unity currently not support to upload certificate. + # Once it supports, enable the verify. + self.array_cert_verify = False + self.array_ca_cert_path = conf.driver_ssl_cert_path + + self.storage_pools_map = self.get_managed_pools() + + @property + def verify_cert(self): + verify_cert = self.array_cert_verify + if verify_cert and self.array_ca_cert_path is not None: + verify_cert = self.array_ca_cert_path + return verify_cert + + @property + def client(self): + if self._client is None: + self._client = client.UnityClient( + self.ip, + self.username, + self.password, + verify_cert=self.verify_cert) + return self._client + + @property + def serial_number(self): + if self._serial_number is None: + self._serial_number = self.client.get_serial() + return self._serial_number + + def get_managed_pools(self): + names = self.configured_pool_names + array_pools = self.client.get_pools() + valid_names = utils.validate_pool_names(names, array_pools.name) + return {p.name: p for p in array_pools if p.name in valid_names} + + def create_volume(self, volume): + """Creates a volume. + + :param volume: volume information + """ + volume_size = volume.size + volume_name = volume.name + volume_description = (volume.display_description + if volume.display_description + else volume.display_name) + + pool = self._get_target_pool(volume) + qos_specs = utils.get_backend_qos_specs(volume) + limit_policy = self.client.get_io_limit_policy(qos_specs) + + LOG.info(_LI('Create Volume: %(volume)s Size: %(size)s ' + 'Pool: %(pool)s Qos: %(qos)s.'), + {'volume': volume_name, + 'size': volume_size, + 'pool': pool.name, + 'qos': qos_specs}) + + lun = self.client.create_lun( + volume_name, volume_size, pool, description=volume_description, + io_limit_policy=limit_policy) + location = self._build_provider_location( + lun_type='lun', + lun_id=lun.get_id()) + model_update = {'provider_location': location} + return model_update + + def delete_volume(self, volume): + lun_id = self.get_lun_id(volume) + if lun_id is None: + LOG.info(_LI('Backend LUN not found, skipping the deletion. ' + 'Volume: %(volume_name)s.'), + {'volume_name': volume.name}) + else: + self.client.delete_lun(lun_id) + + def _initialize_connection(self, lun_or_snap, connector, vol_id): + host = self.client.create_host(connector['host'], + self.get_connector_uids(connector)) + hlu = self.client.attach(host, lun_or_snap) + data = self.get_connection_info(hlu, host, connector) + data['target_discovered'] = True + if vol_id is not None: + data['volume_id'] = vol_id + conn_info = { + 'driver_volume_type': self.driver_volume_type, + 'data': data, + } + LOG.debug('Initialized connection info: %s', conn_info) + return conn_info + + def initialize_connection(self, volume, connector): + lun = self.client.get_lun(lun_id=self.get_lun_id(volume)) + return self._initialize_connection(lun, connector, volume.id) + + def _terminate_connection(self, lun_or_snap, connector): + host = self.client.get_host(connector['host']) + self.client.detach(host, lun_or_snap) + + def terminate_connection(self, volume, connector): + lun = self.client.get_lun(lun_id=self.get_lun_id(volume)) + self._terminate_connection(lun, connector) + + def get_connector_uids(self, connector): + return None + + def get_connection_info(self, hlu, host, connector): + return {} + + def extend_volume(self, volume, new_size): + lun_id = self.get_lun_id(volume) + if lun_id is None: + msg = (_('Backend LUN not found for Volume: %(volume_name)s.') % + {'volume_name': volume.name}) + raise exception.VolumeBackendAPIException(data=msg) + else: + self.client.extend_lun(lun_id, new_size) + + def _get_target_pool(self, volume): + return self.storage_pools_map[utils.get_pool_name(volume)] + + def _build_provider_location(self, lun_id=None, lun_type=None): + return utils.build_provider_location( + system=self.serial_number, + lun_type=lun_type, + lun_id=lun_id, + version=self.version) + + def update_volume_stats(self): + return { + 'volume_backend_name': self.volume_backend_name, + 'storage_protocol': self.protocol, + 'thin_provisioning_support': True, + 'thick_provisioning_support': False, + 'pools': self.get_pools_stats(), + } + + def get_pools_stats(self): + self.storage_pools_map = self.get_managed_pools() + return [self._get_pool_stats(pool) for pool in self.pools] + + @property + def pools(self): + return self.storage_pools_map.values() + + def _get_pool_stats(self, pool): + return { + 'pool_name': pool.name, + 'total_capacity_gb': utils.byte_to_gib(pool.size_total), + 'provisioned_capacity_gb': utils.byte_to_gib( + pool.size_subscribed), + 'free_capacity_gb': utils.byte_to_gib(pool.size_free), + 'reserved_percentage': self.reserved_percentage, + 'location_info': ('%(pool_name)s|%(array_serial)s' % + {'pool_name': pool.name, + 'array_serial': self.serial_number}), + 'thin_provisioning_support': True, + 'thick_provisioning_support': False, + 'max_over_subscription_ratio': ( + self.max_over_subscription_ratio)} + + def get_lun_id(self, volume): + """Retrieves id of the volume's backing LUN. + + :param volume: volume information + """ + if volume.provider_location: + return utils.extract_provider_location(volume.provider_location, + 'id') + else: + # In some cases, cinder will not update volume info in DB with + # provider_location returned by us. We need to retrieve the id + # from array. + lun = self.client.get_lun(name=volume.name) + return lun.get_id() if lun is not None else None + + def create_snapshot(self, snapshot): + """Creates a snapshot. + + :param snapshot: snapshot information. + """ + src_lun_id = self.get_lun_id(snapshot.volume) + return self.client.create_snap(src_lun_id, snapshot.name) + + def delete_snapshot(self, snapshot): + """Deletes a snapshot. + + :param snapshot: the snapshot to delete. + """ + snap = self.client.get_snap(name=snapshot.name) + self.client.delete_snap(snap) + + def _get_referenced_lun(self, existing_ref): + if 'source-id' in existing_ref: + lun = self.client.get_lun(lun_id=existing_ref['source-id']) + elif 'source-name' in existing_ref: + lun = self.client.get_lun(name=existing_ref['source-name']) + else: + reason = _('Reference must contain source-id or source-name key.') + raise exception.ManageExistingInvalidReference( + existing_ref=existing_ref, reason=reason) + if lun is None or not lun.existed: + raise exception.ManageExistingInvalidReference( + existing_ref=existing_ref, + reason=_("LUN doesn't exist.")) + return lun + + def manage_existing(self, volume, existing_ref): + """Manages an existing LUN in the array. + + The LUN should be in a manageable pool backend, otherwise return error. + Rename the backend storage object so that it matches the + `volume['name']` which is how drivers traditionally map between a + cinder volume and the associated backend storage object. + + LUN ID or name are supported in `existing_ref`, like: + + .. code-block:: + + existing_ref:{ + 'source-id': + } + + or + + .. code-block:: + + existing_ref:{ + 'source-name': + } + """ + lun = self._get_referenced_lun(existing_ref) + lun.modify(name=volume.name) + return {'provider_location': + self._build_provider_location(lun_id=lun.get_id(), + lun_type='lun')} + + def manage_existing_get_size(self, volume, existing_ref): + """Returns size of volume to be managed by `manage_existing`. + + The driver does some check here: + 1. The LUN `existing_ref` should be managed by the `volume.host`. + """ + lun = self._get_referenced_lun(existing_ref) + target_pool_name = utils.get_pool_name(volume) + lun_pool_name = lun.pool.name + if target_pool_name and lun_pool_name != target_pool_name: + reason = (_('The imported LUN is in pool %(pool_name)s ' + 'which is not managed by the host %(host)s.') % + {'pool_name': lun_pool_name, + 'host': volume.host}) + raise exception.ManageExistingInvalidReference( + existing_ref=existing_ref, reason=reason) + + return utils.byte_to_gib(lun.size_total) + + def _disconnect_device(self, conn): + conn['connector'].disconnect_volume(conn['conn']['data'], + conn['device']) + + def _connect_device(self, conn): + return self.driver._connect_device(conn) + + @contextlib.contextmanager + def _connect_resource(self, lun_or_snap, connector, res_id): + """Connects to LUN or snapshot, and makes sure disconnect finally. + + :param lun_or_snap: the LUN or snapshot to connect/disconnect. + :param connector: the host connector information. + :param res_id: the ID of the LUN or snapshot. + + :return the connection information, in a dict with format like (same as + the one returned by `_connect_device`): + { + 'conn': , + 'device': , + 'connector': + } + """ + init_conn_func = functools.partial(self._initialize_connection, + lun_or_snap, connector, res_id) + term_conn_func = functools.partial(self._terminate_connection, + lun_or_snap, connector) + with utils.assure_cleanup(init_conn_func, term_conn_func, + False) as conn_info: + conn_device_func = functools.partial(self._connect_device, + conn_info) + with utils.assure_cleanup(conn_device_func, + self._disconnect_device, + True) as attach_info: + yield attach_info + + def _create_volume_from_snap(self, volume, snap, size_in_m=None): + """Creates a volume from a Unity snapshot. + + It attaches the `volume` and `snap`, then use `dd` to copy the + data from the Unity snapshot to the `volume`. + """ + model_update = self.create_volume(volume) + volume.provider_location = model_update['provider_location'] + src_id = snap.get_id() + dest_lun = self.client.get_lun(lun_id=self.get_lun_id(volume)) + try: + conn_props = cinder_utils.brick_get_connector_properties() + + with self._connect_resource(dest_lun, conn_props, + volume.id) as dest_info, \ + self._connect_resource(snap, conn_props, + src_id) as src_info: + if size_in_m is None: + # If size is not specified, need to get the size from LUN + # of snapshot. + lun = self.client.get_lun( + lun_id=snap.storage_resource.get_id()) + size_in_m = utils.byte_to_mib(lun.size_total) + vol_utils.copy_volume( + src_info['device']['path'], + dest_info['device']['path'], + size_in_m, + self.driver.configuration.volume_dd_blocksize, + sparse=True) + except Exception: + with excutils.save_and_reraise_exception(): + utils.ignore_exception(self.delete_volume, volume) + LOG.error(_LE('Failed to create cloned volume: %(vol_id)s, ' + 'from source unity snapshot: %(snap_name)s. '), + {'vol_id': volume.id, 'snap_name': snap.name}) + + return model_update + + def create_volume_from_snapshot(self, volume, snapshot): + snap = self.client.get_snap(snapshot.name) + return self._create_volume_from_snap(volume, snap) + + def create_cloned_volume(self, volume, src_vref): + """Creates cloned volume. + + 1. Take an internal snapshot of source volume, and attach it. + 2. Create a new volume, and attach it. + 3. Copy from attached snapshot of step 1 to the volume of step 2. + 4. Delete the internal snapshot created in step 1. + """ + + src_lun_id = self.get_lun_id(src_vref) + if src_lun_id is None: + raise exception.VolumeBackendAPIException( + data=_("LUN ID of source volume: %s not found.") % + src_vref.name) + src_snap_name = 'snap_clone_%s' % volume.id + + create_snap_func = functools.partial(self.client.create_snap, + src_lun_id, src_snap_name) + with utils.assure_cleanup(create_snap_func, + self.client.delete_snap, + True) as src_snap: + LOG.debug('Internal snapshot for clone is created, ' + 'name: %(name)s, id: %(id)s.', + {'name': src_snap_name, + 'id': src_snap.get_id()}) + return self._create_volume_from_snap( + volume, src_snap, size_in_m=utils.gib_to_mib(volume.size)) + + def get_pool_name(self, volume): + return self.client.get_pool_name(volume.name) + + +class ISCSIAdapter(CommonAdapter): + protocol = PROTOCOL_ISCSI + driver_name = 'UnityISCSIDriver' + driver_volume_type = 'iscsi' + + def get_connector_uids(self, connector): + return utils.extract_iscsi_uids(connector) + + def get_connection_info(self, hlu, host, connector): + targets = self.client.get_iscsi_target_info() + if not targets: + msg = _("There is no accessible iSCSI targets on the system.") + raise exception.VolumeBackendAPIException(data=msg) + one_target = random.choice(targets) + portals = [a['portal'] for a in targets] + iqns = [a['iqn'] for a in targets] + data = { + 'target_luns': [hlu] * len(portals), + 'target_iqns': iqns, + 'target_portals': portals, + 'target_lun': hlu, + 'target_portal': one_target['portal'], + 'target_iqn': one_target['iqn'], + } + return data + + +class FCAdapter(CommonAdapter): + protocol = PROTOCOL_FC + driver_name = 'UnityFCDriver' + driver_volume_type = 'fibre_channel' + + def __init__(self, version=None): + super(FCAdapter, self).__init__(version=version) + self.lookup_service = None + + def do_setup(self, driver, config): + super(FCAdapter, self).do_setup(driver, config) + self.lookup_service = utils.create_lookup_service() + + def get_connector_uids(self, connector): + return utils.extract_fc_uids(connector) + + @property + def auto_zone_enabled(self): + return self.lookup_service is not None + + def get_connection_info(self, hlu, host, connector): + targets = self.client.get_fc_target_info( + host, logged_in_only=(not self.auto_zone_enabled)) + + if not targets: + msg = _("There is no accessible fibre channel targets on the " + "system.") + raise exception.VolumeBackendAPIException(data=msg) + + if self.auto_zone_enabled: + data = self._get_fc_zone_info(connector['wwpns'], targets) + else: + data = { + 'target_wwn': targets, + } + data['target_lun'] = hlu + return data + + def terminate_connection(self, volume, connector): + super(FCAdapter, self).terminate_connection(volume, connector) + + ret = None + if self.auto_zone_enabled: + ret = { + 'driver_volume_type': self.driver_volume_type, + 'data': {} + } + host = self.client.get_host(connector['host']) + if len(host.host_luns) == 0: + targets = self.client.get_fc_target_info(logged_in_only=True) + ret['data'] = self._get_fc_zone_info(connector['wwpns'], + targets) + + return ret + + def _get_fc_zone_info(self, initiator_wwns, target_wwns): + mapping = self.lookup_service.get_device_mapping_from_network( + initiator_wwns, target_wwns) + targets, itor_tgt_map = utils.convert_to_itor_tgt_map(mapping) + return { + 'target_wwn': targets, + 'initiator_target_map': itor_tgt_map, + } diff --git a/cinder/volume/drivers/dell_emc/unity/client.py b/cinder/volume/drivers/dell_emc/unity/client.py new file mode 100644 index 00000000000..e4f4210fd4f --- /dev/null +++ b/cinder/volume/drivers/dell_emc/unity/client.py @@ -0,0 +1,279 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_log import log +from oslo_utils import excutils +from oslo_utils import importutils + +storops = importutils.try_import('storops') +if storops: + from storops import exception as storops_ex +else: + # Set storops_ex to be None for unit test + storops_ex = None + +from cinder import exception +from cinder.i18n import _, _LW +from cinder.volume.drivers.dell_emc.unity import utils + + +LOG = log.getLogger(__name__) + + +class UnityClient(object): + def __init__(self, host, username, password, verify_cert=True): + if storops is None: + msg = _('Python package storops is not installed which ' + 'is required to run Unity driver.') + raise exception.VolumeBackendAPIException(data=msg) + self._system = None + self.host = host + self.username = username + self.password = password + self.verify_cert = verify_cert + + @property + def system(self): + if self._system is None: + self._system = storops.UnitySystem( + host=self.host, username=self.username, password=self.password, + verify=self.verify_cert) + return self._system + + def get_serial(self): + return self.system.serial_number + + def create_lun(self, name, size, pool, description=None, + io_limit_policy=None): + """Creates LUN on the Unity system. + + :param name: lun name + :param size: lun size in GiB + :param pool: UnityPool object represent to pool to place the lun + :param description: lun description + :param io_limit_policy: io limit on the LUN + :return: UnityLun object + """ + try: + lun = pool.create_lun(lun_name=name, size_gb=size, + description=description, + io_limit_policy=io_limit_policy) + except storops_ex.UnityLunNameInUseError: + LOG.debug("LUN %s already exists. Return the existing one.", + name) + lun = self.system.get_lun(name=name) + return lun + + def delete_lun(self, lun_id): + """Deletes LUN on the Unity system. + + :param lun_id: id of the LUN + """ + try: + lun = self.system.get_lun(_id=lun_id) + lun.delete() + except storops_ex.UnityResourceNotFoundError: + LOG.debug("LUN %s doesn't exist. Deletion is not needed.", + lun_id) + + def get_lun(self, lun_id=None, name=None): + """Gets LUN on the Unity system. + + :param lun_id: id of the LUN + :param name: name of the LUN + :return: `UnityLun` object + """ + lun = None + if lun_id is None and name is None: + LOG.warning( + _LW("Both lun_id and name are None to get LUN. Return None.")) + else: + try: + lun = self.system.get_lun(_id=lun_id, name=name) + except storops_ex.UnityResourceNotFoundError: + LOG.warning( + _LW("LUN id=%(id)s, name=%(name)s doesn't exist."), + {'id': lun_id, 'name': name}) + return lun + + def extend_lun(self, lun_id, size_gib): + lun = self.system.get_lun(lun_id) + try: + lun.total_size_gb = size_gib + except storops_ex.UnityNothingToModifyError: + LOG.debug("LUN %s is already expanded. LUN expand is not needed.", + lun_id) + return lun + + def get_pools(self): + """Gets all storage pools on the Unity system. + + :return: list of UnityPool object + """ + return self.system.get_pool() + + def create_snap(self, src_lun_id, name=None): + """Creates a snapshot of LUN on the Unity system. + + :param src_lun_id: the source LUN ID of the snapshot. + :param name: the name of the snapshot. The Unity system will give one + if `name` is None. + """ + try: + lun = self.get_lun(lun_id=src_lun_id) + snap = lun.create_snap(name, is_auto_delete=False) + except storops_ex.UnitySnapNameInUseError as err: + LOG.debug( + "Snap %(snap_name)s already exists on LUN %(lun_id)s. " + "Return the existing one. Message: %(err)s", + {'snap_name': name, + 'lun_id': src_lun_id, + 'err': err}) + snap = self.get_snap(name=name) + return snap + + @staticmethod + def delete_snap(snap): + if snap is None: + LOG.debug("Snap to delete is None, skipping deletion.") + return + + try: + snap.delete() + except storops_ex.UnityResourceNotFoundError as err: + LOG.debug("Snap %(snap_name)s may be deleted already. " + "Message: %(err)s", + {'snap_name': snap.name, + 'err': err}) + except storops_ex.UnityDeleteAttachedSnapError as err: + with excutils.save_and_reraise_exception(): + LOG.warning(_LW("Failed to delete snapshot %(snap_name)s " + "which is in use. Message: %(err)s"), + {'snap_name': snap.name, 'err': err}) + + def get_snap(self, name=None): + try: + return self.system.get_snap(name=name) + except storops_ex.UnityResourceNotFoundError as err: + msg = _LW("Snapshot %(name)s doesn't exist. Message: %(err)s") + LOG.warning(msg, {'name': name, 'err': err}) + return None + + def create_host(self, name, uids): + """Creates a host on Unity. + + Creates a host on Unity which has the uids associated. + + :param name: name of the host + :param uids: iqns or wwns list + :return: UnitHost object + """ + + try: + host = self.system.get_host(name=name) + except storops_ex.UnityResourceNotFoundError: + LOG.debug('Existing host %s not found. Create a new one.', name) + host = self.system.create_host(name=name) + + host_initiators_ids = self.get_host_initiator_ids(host) + un_registered = filter(lambda h: h not in host_initiators_ids, uids) + for uid in un_registered: + host.add_initiator(uid, force_create=True) + + host.update() + return host + + @staticmethod + def get_host_initiator_ids(host): + fc = host.fc_host_initiators + fc_ids = [] if fc is None else fc.initiator_id + iscsi = host.iscsi_host_initiators + iscsi_ids = [] if iscsi is None else iscsi.initiator_id + return fc_ids + iscsi_ids + + @staticmethod + def attach(host, lun_or_snap): + """Attaches a `UnityLun` or `UnitySnap` to a `UnityHost`. + + :param host: `UnityHost` object + :param lun_or_snap: `UnityLun` or `UnitySnap` object + :return: hlu + """ + try: + return host.attach(lun_or_snap, skip_hlu_0=True) + except storops_ex.UnityResourceAlreadyAttachedError: + return host.get_hlu(lun_or_snap) + + @staticmethod + def detach(host, lun_or_snap): + """Detaches a `UnityLun` or `UnitySnap` from a `UnityHost`. + + :param host: `UnityHost` object + :param lun_or_snap: `UnityLun` object + """ + lun_or_snap.update() + host.detach(lun_or_snap) + + def get_host(self, name): + return self.system.get_host(name=name) + + def get_iscsi_target_info(self): + portals = self.system.get_iscsi_portal() + return [{'portal': utils.convert_ip_to_portal(p.ip_address), + 'iqn': p.iscsi_node.name} + for p in portals] + + def get_fc_target_info(self, host=None, logged_in_only=False): + """Get the ports WWN of FC on array. + + :param host: the host to which the FC port is registered. + :param logged_in_only: whether to retrieve only the logged-in port. + + :return the WWN of FC ports. For example, the FC WWN on array is like: + 50:06:01:60:89:20:09:25:50:06:01:6C:09:20:09:25. + This function removes the colons and returns the last 16 bits: + 5006016C09200925. + """ + ports = [] + if logged_in_only: + for host_initiator in host.fc_host_initiators: + paths = host_initiator.paths or [] + for path in paths: + if path.is_logged_in: + ports.append(path.fc_port) + else: + ports = self.system.get_fc_port() + return [po.wwn.replace(':', '')[16:] for po in ports] + + def create_io_limit_policy(self, name, max_iops=None, max_kbps=None): + try: + limit = self.system.create_io_limit_policy( + name, max_iops=max_iops, max_kbps=max_kbps) + except storops_ex.UnityPolicyNameInUseError: + limit = self.system.get_io_limit_policy(name=name) + return limit + + def get_io_limit_policy(self, qos_specs): + limit_policy = None + if qos_specs is not None: + limit_policy = self.create_io_limit_policy( + qos_specs['id'], + qos_specs.get(utils.QOS_MAX_IOPS), + qos_specs.get(utils.QOS_MAX_BWS)) + return limit_policy + + def get_pool_name(self, lun_name): + lun = self.system.get_lun(name=lun_name) + return lun.pool_name diff --git a/cinder/volume/drivers/dell_emc/unity/driver.py b/cinder/volume/drivers/dell_emc/unity/driver.py new file mode 100644 index 00000000000..3fcf7ab4e99 --- /dev/null +++ b/cinder/volume/drivers/dell_emc/unity/driver.py @@ -0,0 +1,216 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Cinder Driver for Unity""" + +from oslo_config import cfg +from oslo_log import log as logging + +from cinder import interface +from cinder.volume import driver +from cinder.volume.drivers.dell_emc.unity import adapter +from cinder.volume.drivers.san.san import san_opts +from cinder.zonemanager import utils as zm_utils + +LOG = logging.getLogger(__name__) + +CONF = cfg.CONF + +UNITY_OPTS = [ + cfg.ListOpt('unity_storage_pool_names', + default=None, + help='A comma-separated list of storage pool names ' + 'to be used.')] + +CONF.register_opts(UNITY_OPTS) + + +@interface.volumedriver +class UnityDriver(driver.TransferVD, + driver.ManageableVD, + driver.ExtendVD, + driver.SnapshotVD, + driver.ManageableSnapshotsVD, + driver.BaseVD): + """Unity Driver. + + Version history: + 1.0.0 - Initial version + """ + + VERSION = '01.00.00' + VENDOR = 'Dell EMC' + # ThirdPartySystems wiki page + CI_WIKI_NAME = "EMC_UNITY_CI" + + def __init__(self, *args, **kwargs): + super(UnityDriver, self).__init__(*args, **kwargs) + self.configuration.append_config_values(UNITY_OPTS) + self.configuration.append_config_values(san_opts) + protocol = self.configuration.storage_protocol + if protocol.lower() == adapter.PROTOCOL_FC.lower(): + self.protocol = adapter.PROTOCOL_FC + self.adapter = adapter.FCAdapter(self.VERSION) + else: + self.protocol = adapter.PROTOCOL_ISCSI + self.adapter = adapter.ISCSIAdapter(self.VERSION) + + def do_setup(self, context): + self.adapter.do_setup(self, self.configuration) + + def check_for_setup_error(self): + pass + + def create_volume(self, volume): + """Creates a volume.""" + return self.adapter.create_volume(volume) + + def create_volume_from_snapshot(self, volume, snapshot): + """Creates a volume from a snapshot.""" + return self.adapter.create_volume_from_snapshot(volume, snapshot) + + def create_cloned_volume(self, volume, src_vref): + """Creates a cloned volume.""" + return self.adapter.create_cloned_volume(volume, src_vref) + + def extend_volume(self, volume, new_size): + """Extend a volume.""" + self.adapter.extend_volume(volume, new_size) + + def delete_volume(self, volume): + """Deletes a volume.""" + self.adapter.delete_volume(volume) + + def create_snapshot(self, snapshot): + """Creates a snapshot.""" + self.adapter.create_snapshot(snapshot) + + def delete_snapshot(self, snapshot): + """Deletes a snapshot.""" + self.adapter.delete_snapshot(snapshot) + + def ensure_export(self, context, volume): + """Driver entry point to get the export info for an existing volume.""" + pass + + def create_export(self, context, volume, connector): + """Driver entry point to get the export info for a new volume.""" + pass + + def remove_export(self, context, volume): + """Driver entry point to remove an export for a volume.""" + pass + + def check_for_export(self, context, volume_id): + """Make sure volume is exported.""" + pass + + @zm_utils.AddFCZone + def initialize_connection(self, volume, connector): + """Initializes the connection and returns connection info. + + Assign any created volume to a compute node/host so that it can be + used from that host. + + The driver returns a driver_volume_type of 'fibre_channel'. + The target_wwn can be a single entry or a list of wwns that + correspond to the list of remote wwn(s) that will export the volume. + The initiator_target_map is a map that represents the remote wwn(s) + and a list of wwns which are visible to the remote wwn(s). + Example return values: + FC: + { + 'driver_volume_type': 'fibre_channel' + 'data': { + 'target_discovered': True, + 'target_lun': 1, + 'target_wwn': ['1234567890123', '0987654321321'], + 'initiator_target_map': { + '1122334455667788': ['1234567890123', + '0987654321321'] + } + } + } + iSCSI: + { + 'driver_volume_type': 'iscsi' + 'data': { + 'target_discovered': True, + 'target_iqns': ['iqn.2010-10.org.openstack:volume-00001', + 'iqn.2010-10.org.openstack:volume-00002'], + 'target_portals': ['127.0.0.1:3260', '127.0.1.1:3260'], + 'target_luns': [1, 1], + } + } + """ + LOG.debug("Entering initialize_connection" + " - connector: %(connector)s.", + {'connector': connector}) + conn_info = self.adapter.initialize_connection(volume, + connector) + LOG.debug("Exit initialize_connection" + " - Returning connection info: %(conn_info)s.", + {'conn_info': conn_info}) + return conn_info + + @zm_utils.RemoveFCZone + def terminate_connection(self, volume, connector, **kwargs): + """Disallow connection from connector.""" + LOG.debug("Entering terminate_connection" + " - connector: %(connector)s.", + {'connector': connector}) + conn_info = self.adapter.terminate_connection(volume, connector) + LOG.debug("Exit terminate_connection" + " - Returning connection info: %(conn_info)s.", + {'conn_info': conn_info}) + return conn_info + + def get_volume_stats(self, refresh=False): + """Get volume stats. + + :param refresh: True to get updated data + """ + if refresh: + self.update_volume_stats() + + return self._stats + + def update_volume_stats(self): + """Retrieve stats info from volume group.""" + LOG.debug("Updating volume stats.") + stats = self.adapter.update_volume_stats() + stats['driver_version'] = self.VERSION + stats['vendor_name'] = self.VENDOR + self._stats = stats + + def manage_existing(self, volume, existing_ref): + """Manages an existing LUN in the array. + + :param volume: the mapping cinder volume of the Unity LUN. + :param existing_ref: the Unity LUN info. + """ + return self.adapter.manage_existing(volume, existing_ref) + + def manage_existing_get_size(self, volume, existing_ref): + """Returns size of volume to be managed by manage_existing.""" + return self.adapter.manage_existing_get_size(volume, existing_ref) + + def get_pool(self, volume): + """Returns the pool name of a volume.""" + return self.adapter.get_pool_name(volume) + + def unmanage(self, volume): + """Unmanages a volume.""" + pass diff --git a/cinder/volume/drivers/dell_emc/unity/utils.py b/cinder/volume/drivers/dell_emc/unity/utils.py new file mode 100644 index 00000000000..5255462f006 --- /dev/null +++ b/cinder/volume/drivers/dell_emc/unity/utils.py @@ -0,0 +1,263 @@ +# Copyright (c) 2016 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import division + +import contextlib +import functools +from oslo_log import log as logging +from oslo_utils import units +import six + +from cinder import exception +from cinder.i18n import _, _LW +from cinder.volume import utils as vol_utils +from cinder.volume import volume_types +from cinder.zonemanager import utils as zm_utils + +LOG = logging.getLogger(__name__) +BACKEND_QOS_CONSUMERS = frozenset(['back-end', 'both']) +QOS_MAX_IOPS = 'maxIOPS' +QOS_MAX_BWS = 'maxBWS' + + +def dump_provider_location(location_dict): + sorted_keys = sorted(location_dict.keys()) + return '|'.join('%(k)s^%(v)s' % {'k': k, 'v': location_dict[k]} + for k in sorted_keys) + + +def build_provider_location(system, lun_type, lun_id, version): + """Builds provider_location for volume or snapshot. + + :param system: Unity serial number + :param lun_id: LUN ID in Unity + :param lun_type: 'lun' + :param version: driver version + """ + location_dict = {'system': system, + 'type': lun_type, + 'id': six.text_type(lun_id), + 'version': version} + return dump_provider_location(location_dict) + + +def extract_provider_location(provider_location, key): + """Extracts value of the specified field from provider_location string. + + :param provider_location: provider_location string + :param key: field name of the value that to be extracted + :return: value of the specified field if it exists, otherwise, + None is returned + """ + if provider_location: + for kvp in provider_location.split('|'): + fields = kvp.split('^') + if len(fields) == 2 and fields[0] == key: + return fields[1] + else: + msg = _LW('"%(key)s" is not found in provider ' + 'location "%(location)s."') + LOG.warning(msg, {'key': key, 'location': provider_location}) + else: + LOG.warning(_LW('Empty provider location received.')) + + +def byte_to_gib(byte): + return byte / units.Gi + + +def byte_to_mib(byte): + return byte / units.Mi + + +def gib_to_mib(gib): + return gib * units.Ki + + +def validate_pool_names(conf_pools, array_pools): + if not conf_pools: + LOG.debug('No storage pools are specified. This host will manage ' + 'all the pools on the Unity system.') + return array_pools + + conf_pools = set(map(lambda i: i.strip(), conf_pools)) + array_pools = set(map(lambda i: i.strip(), array_pools)) + existed = conf_pools & array_pools + + if not existed: + msg = (_('No storage pools to be managed exist. Please check ' + 'your configuration. The available storage pools on the ' + 'system are %s.') % array_pools) + raise exception.VolumeBackendAPIException(data=msg) + + return existed + + +def extract_iscsi_uids(connector): + if 'initiator' not in connector: + msg = _("Host %s doesn't have iSCSI initiator.") % connector['host'] + raise exception.VolumeBackendAPIException(data=msg) + + return [connector['initiator']] + + +def extract_fc_uids(connector): + if 'wwnns' not in connector or 'wwpns' not in connector: + msg = _("Host %s doesn't have FC initiators.") % connector['host'] + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + wwnns = connector['wwnns'] + wwpns = connector['wwpns'] + wwns = [(node + port).upper() for node, port in zip(wwnns, wwpns)] + + def _to_wwn(wwn): + # Format the wwn to include the colon + # For example, convert 1122200000051E55E100 to + # 11:22:20:00:00:05:1E:55:A1:00 + return ':'.join(wwn[i:i + 2] for i in range(0, len(wwn), 2)) + + return list(map(_to_wwn, wwns)) + + +def convert_ip_to_portal(ip): + return '%s:3260' % ip + + +def convert_to_itor_tgt_map(zone_mapping): + """Function to process data from lookup service. + + :param zone_mapping: mapping is the data from the zone lookup service + with below format + { + : { + 'initiator_port_wwn_list': + ('200000051e55a100', '200000051e55a121'..) + 'target_port_wwn_list': + ('100000051e55a100', '100000051e55a121'..) + } + } + """ + target_wwns = [] + itor_tgt_map = {} + for san_name in zone_mapping: + one_map = zone_mapping[san_name] + for target in one_map['target_port_wwn_list']: + if target not in target_wwns: + target_wwns.append(target) + for initiator in one_map['initiator_port_wwn_list']: + itor_tgt_map[initiator] = one_map['target_port_wwn_list'] + LOG.debug("target_wwns: %(tgt_wwns)s\n init_targ_map: %(itor_tgt_map)s", + {'tgt_wwns': target_wwns, + 'itor_tgt_map': itor_tgt_map}) + return target_wwns, itor_tgt_map + + +def get_pool_name(volume): + return vol_utils.extract_host(volume.host, 'pool') + + +def get_extra_spec(volume, spec_key): + spec_value = None + type_id = volume.volume_type_id + if type_id is not None: + extra_specs = volume_types.get_volume_type_extra_specs(type_id) + if spec_key in extra_specs: + spec_value = extra_specs[spec_key] + return spec_value + + +def ignore_exception(func, *args, **kwargs): + try: + func(*args, **kwargs) + except Exception as ex: + LOG.warning(_LW('Error occurred but ignored. Function: %(func_name)s, ' + 'args: %(args)s, kwargs: %(kwargs)s, ' + 'exception: %(ex)s.'), + {'func_name': func, 'args': args, + 'kwargs': kwargs, 'ex': ex}) + + +@contextlib.contextmanager +def assure_cleanup(enter_func, exit_func, use_enter_return): + """Assures the resource is cleaned up. Used as a context. + + :param enter_func: the function to execute when entering the context. + :param exit_func: the function to execute when leaving the context. + :param use_enter_return: the flag indicates whether to pass the return + value of enter_func in to the exit_func. + """ + + enter_return = None + try: + if isinstance(enter_func, functools.partial): + enter_func_name = enter_func.func.__name__ + else: + enter_func_name = enter_func.__name__ + LOG.debug(('Entering context. Function: %(func_name)s, ' + 'use_enter_return: %(use)s.'), + {'func_name': enter_func_name, + 'use': use_enter_return}) + enter_return = enter_func() + yield enter_return + finally: + if isinstance(exit_func, functools.partial): + exit_func_name = exit_func.func.__name__ + else: + exit_func_name = exit_func.__name__ + LOG.debug(('Exiting context. Function: %(func_name)s, ' + 'use_enter_return: %(use)s.'), + {'func_name': exit_func_name, + 'use': use_enter_return}) + if enter_return is not None: + if use_enter_return: + ignore_exception(exit_func, enter_return) + else: + ignore_exception(exit_func) + + +def create_lookup_service(): + return zm_utils.create_lookup_service() + + +def get_backend_qos_specs(volume): + type_id = volume.volume_type_id + if type_id is None: + return None + + qos_specs = volume_types.get_volume_type_qos_specs(type_id) + if qos_specs is None: + return None + + qos_specs = qos_specs['qos_specs'] + if qos_specs is None: + return None + + consumer = qos_specs['consumer'] + # Front end QoS specs are handled by nova. We ignore them here. + if consumer not in BACKEND_QOS_CONSUMERS: + return None + + max_iops = qos_specs['specs'].get(QOS_MAX_IOPS) + max_bws = qos_specs['specs'].get(QOS_MAX_BWS) + if max_iops is None and max_bws is None: + return None + + return { + 'id': qos_specs['id'], + QOS_MAX_IOPS: max_iops, + QOS_MAX_BWS: max_bws, + } diff --git a/releasenotes/notes/dell-emc-unity-driver-72cb901467b23b22.yaml b/releasenotes/notes/dell-emc-unity-driver-72cb901467b23b22.yaml new file mode 100644 index 00000000000..9e43264aca5 --- /dev/null +++ b/releasenotes/notes/dell-emc-unity-driver-72cb901467b23b22.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added backend driver for Dell EMC Unity storage.