From 7f7f13bcac84d0086110a82306832bc5453be0d9 Mon Sep 17 00:00:00 2001 From: nikeshm Date: Mon, 27 Apr 2015 12:23:11 +0530 Subject: [PATCH] Adds FC and ISCSI Cinder drivers for DotHill Storage Arrays DocImpact Implements: blueprint dothill-fc-cinder-driver Implements: blueprint dothill-iscsi-cinder-driver Co-Authored-By: Gauvain Pocentek Co-Authored-By: Walter A. Boring IV Change-Id: Iaf70ade96d4ad4234cc7e88277ed7b52cf458c2a --- cinder/exception.py | 25 + cinder/tests/unit/test_dothill.py | 733 ++++++++++++++++++ cinder/volume/drivers/dothill/__init__.py | 0 .../volume/drivers/dothill/dothill_client.py | 336 ++++++++ .../volume/drivers/dothill/dothill_common.py | 542 +++++++++++++ cinder/volume/drivers/dothill/dothill_fc.py | 172 ++++ .../volume/drivers/dothill/dothill_iscsi.py | 195 +++++ 7 files changed, 2003 insertions(+) create mode 100644 cinder/tests/unit/test_dothill.py create mode 100644 cinder/volume/drivers/dothill/__init__.py create mode 100644 cinder/volume/drivers/dothill/dothill_client.py create mode 100644 cinder/volume/drivers/dothill/dothill_common.py create mode 100644 cinder/volume/drivers/dothill/dothill_fc.py create mode 100644 cinder/volume/drivers/dothill/dothill_iscsi.py diff --git a/cinder/exception.py b/cinder/exception.py index bcb86300f..19eaacfb1 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -912,3 +912,28 @@ class StorPoolConfigurationMissing(CinderException): class StorPoolConfigurationInvalid(CinderException): message = _("Invalid parameter %(param)s in the %(section)s section " "of the /etc/storpool.conf file: %(error)s") + + +# DOTHILL drivers +class DotHillInvalidBackend(CinderException): + message = _("Backend doesn't exist (%(backend)s)") + + +class DotHillConnectionError(CinderException): + message = _("%(message)s") + + +class DotHillAuthenticationError(CinderException): + message = _("%(message)s") + + +class DotHillNotEnoughSpace(CinderException): + message = _("Not enough space on backend (%(backend)s)") + + +class DotHillRequestError(CinderException): + message = _("%(message)s") + + +class DotHillNotTargetPortal(CinderException): + message = _("No active iSCSI portals with supplied iSCSI IPs") diff --git a/cinder/tests/unit/test_dothill.py b/cinder/tests/unit/test_dothill.py new file mode 100644 index 000000000..4438da474 --- /dev/null +++ b/cinder/tests/unit/test_dothill.py @@ -0,0 +1,733 @@ +# Copyright 2014 Objectif Libre +# Copyright 2015 DotHill Systems +# +# 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. +# +"""Unit tests for OpenStack Cinder DotHill driver.""" + +import urllib2 + +from lxml import etree +import mock + +from cinder import exception +from cinder import test +from cinder.volume.drivers.dothill import dothill_client as dothill +from cinder.volume.drivers.dothill import dothill_common +from cinder.volume.drivers.dothill import dothill_fc +from cinder.volume.drivers.dothill import dothill_iscsi +from cinder.zonemanager import utils as fczm_utils + +session_key = '12a1626754554a21d85040760c81b' +resp_login = ''' + success + 0 + 12a1626754554a21d85040760c81b + 1''' +resp_badlogin = ''' + error + 1 + Authentication failure + 1''' +response_ok = ''' + some data + 0 + ''' +response_not_ok = ''' + Error Message + 1 + ''' +response_stats_linear = ''' + 3863830528 + 3863830528 + ''' +response_stats_realstor = ''' + 3863830528 + 3863830528 + ''' +response_no_lun = '''''' +response_lun = ''' + 1 + + 4''' +response_ports = ''' + + FC + id1 + Disconnected + + FC + id2 + Up + + iSCSI + id3 + 10.0.0.10 + Disconnected + + iSCSI + id4 + 10.0.0.11 + Up + + iSCSI + id5 + 10.0.0.12 + Up + ''' + +response_ports_linear = response_ports % {'ip': 'primary-ip-address'} +response_ports_realstor = response_ports % {'ip': 'ip-address'} + + +invalid_xml = '''''' +malformed_xml = '''''' +fake_xml = '''''' + +stats_low_space = {'free_capacity_gb': 10, 'total_capacity_gb': 100} +stats_large_space = {'free_capacity_gb': 90, 'total_capacity_gb': 100} + +vol_id = 'fceec30e-98bc-4ce5-85ff-d7309cc17cc2' +test_volume = {'id': vol_id, 'name_id': None, + 'display_name': 'test volume', 'name': 'volume', 'size': 10} +test_retype_volume = {'attach_status': 'available', 'id': vol_id, + 'name_id': None, 'display_name': 'test volume', + 'name': 'volume', 'size': 10} +test_host = {'capabilities': {'location_info': + 'DotHillVolumeDriver:xxxxx:dg02:A'}} +test_snap = {'id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'volume': {'name_id': None}, + 'volume_id': vol_id, + 'display_name': 'test volume', 'name': 'volume', 'size': 10} +encoded_volid = 'v_O7DDpi8TOWF_9cwnMF' +encoded_snapid = 's_O7DDpi8TOWF_9cwnMF' +dest_volume = {'id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'source_volid': vol_id, + 'display_name': 'test volume', 'name': 'volume', 'size': 10} +attached_volume = {'id': vol_id, + 'display_name': 'test volume', 'name': 'volume', + 'size': 10, 'status': 'in-use', + 'attach_status': 'attached'} +attaching_volume = {'id': vol_id, + 'display_name': 'test volume', 'name': 'volume', + 'size': 10, 'status': 'attaching', + 'attach_status': 'attached'} +detached_volume = {'id': vol_id, 'name_id': None, + 'display_name': 'test volume', 'name': 'volume', + 'size': 10, 'status': 'available', + 'attach_status': 'detached'} + +connector = {'ip': '10.0.0.2', + 'initiator': 'iqn.1993-08.org.debian:01:222', + 'wwpns': ["111111111111111", "111111111111112"], + 'wwnns': ["211111111111111", "211111111111112"], + 'host': 'fakehost'} +invalid_connector = {'ip': '10.0.0.2', + 'initiator': '', + 'wwpns': [], + 'wwnns': [], + 'host': 'fakehost'} + + +class TestDotHillClient(test.TestCase): + def setUp(self): + super(TestDotHillClient, self).setUp() + self.login = 'manage' + self.passwd = '!manage' + self.ip = '10.0.0.1' + self.protocol = 'http' + self.client = dothill.DotHillClient(self.ip, self.login, self.passwd, + self.protocol) + + @mock.patch('urllib2.urlopen') + def test_login(self, mock_url_open): + m = mock.Mock() + m.read.side_effect = [resp_login] + mock_url_open.return_value = m + self.client.login() + self.assertEqual(session_key, self.client._session_key) + m.read.side_effect = [resp_badlogin] + self.assertRaises(exception.DotHillAuthenticationError, + self.client.login) + + def test_build_request_url(self): + url = self.client._build_request_url('/path') + self.assertEqual('http://10.0.0.1/api/path', url) + url = self.client._build_request_url('/path', arg1='val1') + self.assertEqual('http://10.0.0.1/api/path/arg1/val1', url) + url = self.client._build_request_url('/path', arg_1='val1') + self.assertEqual('http://10.0.0.1/api/path/arg-1/val1', url) + url = self.client._build_request_url('/path', 'arg1') + self.assertEqual('http://10.0.0.1/api/path/arg1', url) + url = self.client._build_request_url('/path', 'arg1', arg2='val2') + self.assertEqual('http://10.0.0.1/api/path/arg2/val2/arg1', url) + url = self.client._build_request_url('/path', 'arg1', 'arg3', + arg2='val2') + self.assertEqual('http://10.0.0.1/api/path/arg2/val2/arg1/arg3', url) + + @mock.patch('urllib2.urlopen') + def test_request(self, mock_url_open): + self.client._session_key = session_key + + m = mock.Mock() + m.read.side_effect = [response_ok, malformed_xml, + urllib2.URLError("error")] + mock_url_open.return_value = m + ret = self.client._request('/path') + self.assertTrue(type(ret) == etree._Element) + self.assertRaises(exception.DotHillConnectionError, + self.client._request, + '/path') + self.assertRaises(exception.DotHillConnectionError, + self.client._request, + '/path') + + def test_assert_response_ok(self): + ok_tree = etree.XML(response_ok) + not_ok_tree = etree.XML(response_not_ok) + invalid_tree = etree.XML(invalid_xml) + ret = self.client._assert_response_ok(ok_tree) + self.assertEqual(None, ret) + self.assertRaises(exception.DotHillRequestError, + self.client._assert_response_ok, + not_ok_tree) + self.assertRaises(exception.DotHillRequestError, + self.client._assert_response_ok, invalid_tree) + + @mock.patch.object(dothill.DotHillClient, '_request') + def test_backend_exists(self, mock_request): + mock_request.side_effect = [exception.DotHillRequestError, + fake_xml] + self.assertEqual(False, self.client.backend_exists('backend_name', + 'linear')) + self.assertEqual(True, self.client.backend_exists('backend_name', + 'linear')) + + @mock.patch.object(dothill.DotHillClient, '_request') + def test_backend_stats(self, mock_request): + stats = {'free_capacity_gb': 1979, + 'total_capacity_gb': 1979} + linear = etree.XML(response_stats_linear) + realstor = etree.XML(response_stats_realstor) + mock_request.side_effect = [linear, realstor] + + self.assertEqual(stats, self.client.backend_stats('OpenStack', + 'linear')) + self.assertEqual(stats, self.client.backend_stats('OpenStack', + 'realstor')) + + @mock.patch.object(dothill.DotHillClient, '_request') + def test_get_lun(self, mock_request): + mock_request.side_effect = [etree.XML(response_no_lun), + etree.XML(response_lun)] + ret = self.client._get_first_available_lun_for_host("fakehost") + self.assertEqual(1, ret) + ret = self.client._get_first_available_lun_for_host("fakehost") + self.assertEqual(2, ret) + + @mock.patch.object(dothill.DotHillClient, '_request') + def test_get_ports(self, mock_request): + mock_request.side_effect = [etree.XML(response_ports)] + ret = self.client.get_active_target_ports() + self.assertEqual([{'port-type': 'FC', + 'target-id': 'id2', + 'status': 'Up'}, + {'port-type': 'iSCSI', + 'target-id': 'id4', + 'status': 'Up'}, + {'port-type': 'iSCSI', + 'target-id': 'id5', + 'status': 'Up'}], ret) + + @mock.patch.object(dothill.DotHillClient, '_request') + def test_get_fc_ports(self, mock_request): + mock_request.side_effect = [etree.XML(response_ports)] + ret = self.client.get_active_fc_target_ports() + self.assertEqual(['id2'], ret) + + @mock.patch.object(dothill.DotHillClient, '_request') + def test_get_iscsi_iqns(self, mock_request): + mock_request.side_effect = [etree.XML(response_ports)] + ret = self.client.get_active_iscsi_target_iqns() + self.assertEqual(['id4', 'id5'], ret) + + @mock.patch.object(dothill.DotHillClient, '_request') + def test_get_iscsi_portals(self, mock_request): + portals = {'10.0.0.12': 'Up', '10.0.0.11': 'Up'} + mock_request.side_effect = [etree.XML(response_ports_linear), + etree.XML(response_ports_realstor)] + ret = self.client.get_active_iscsi_target_portals('linear') + self.assertEqual(portals, ret) + ret = self.client.get_active_iscsi_target_portals('realstor') + self.assertEqual(portals, ret) + + +class FakeConfiguration1(object): + dothill_backend_name = 'OpenStack' + dothill_backend_type = 'linear' + san_ip = '10.0.0.1' + san_login = 'manage' + san_password = '!manage' + dothill_wbi_protocol = 'http' + + def safe_get(self, key): + return 'fakevalue' + + +class FakeConfiguration2(FakeConfiguration1): + dothill_iscsi_ips = ['10.0.0.11'] + use_chap_auth = None + + +class TestFCDotHillCommon(test.TestCase): + def setUp(self): + super(TestFCDotHillCommon, self).setUp() + self.config = FakeConfiguration1() + self.common = dothill_common.DotHillCommon(self.config) + self.common.client_login = mock.MagicMock() + self.common.client_logout = mock.MagicMock() + self.common.serialNumber = "xxxxx" + self.common.owner = "A" + self.connector_element = "wwpns" + + @mock.patch.object(dothill.DotHillClient, 'get_serial_number') + @mock.patch.object(dothill.DotHillClient, 'get_owner_info') + @mock.patch.object(dothill.DotHillClient, 'backend_exists') + def test_do_setup(self, mock_backend_exists, + mock_owner_info, mock_serial_number): + mock_backend_exists.side_effect = [False, True] + mock_owner_info.return_value = "A" + mock_serial_number.return_value = "xxxxx" + self.assertRaises(exception.DotHillInvalidBackend, + self.common.do_setup, None) + self.assertEqual(None, self.common.do_setup(None)) + mock_backend_exists.assert_called_with(self.common.backend_name, + self.common.backend_type) + mock_owner_info.assert_called_with(self.common.backend_name) + + def test_vol_name(self): + self.assertEqual(encoded_volid, self.common._get_vol_name(vol_id)) + self.assertEqual(encoded_snapid, self.common._get_snap_name(vol_id)) + + def test_check_flags(self): + class FakeOptions(object): + def __init__(self, d): + for k, v in d.items(): + self.__dict__[k] = v + + options = FakeOptions({'opt1': 'val1', 'opt2': 'val2'}) + required_flags = ['opt1', 'opt2'] + ret = self.common.check_flags(options, required_flags) + self.assertEqual(None, ret) + + options = FakeOptions({'opt1': 'val1', 'opt2': 'val2'}) + required_flags = ['opt1', 'opt2', 'opt3'] + self.assertRaises(exception.Invalid, self.common.check_flags, + options, required_flags) + + def test_assert_connector_ok(self): + self.assertRaises(exception.InvalidInput, + self.common._assert_connector_ok, invalid_connector, + self.connector_element) + self.assertIsNone(self.common._assert_connector_ok( + connector, + self.connector_element)) + + @mock.patch.object(dothill.DotHillClient, 'backend_stats') + def test_update_volume_stats(self, mock_stats): + mock_stats.side_effect = [exception.DotHillRequestError, + stats_large_space] + + self.assertRaises(exception.Invalid, self.common._update_volume_stats) + mock_stats.assert_called_with(self.common.backend_name, + self.common.backend_type) + ret = self.common._update_volume_stats() + + self.assertEqual(None, ret) + self.assertEqual({'driver_version': self.common.VERSION, + 'pools': [{'QoS_support': False, + 'free_capacity_gb': 90, + 'location_info': + 'DotHillVolumeDriver:xxxxx:OpenStack:A', + 'pool_name': 'OpenStack', + 'total_capacity_gb': 100}], + 'storage_protocol': None, + 'vendor_name': 'DotHill', + 'volume_backend_name': None}, self.common.stats) + + @mock.patch.object(dothill.DotHillClient, 'create_volume') + def test_create_volume(self, mock_create): + mock_create.side_effect = [exception.DotHillRequestError, None] + + self.assertRaises(exception.Invalid, self.common.create_volume, + test_volume) + ret = self.common.create_volume(test_volume) + self.assertEqual(None, ret) + mock_create.assert_called_with(encoded_volid, + "%sGB" % test_volume['size'], + self.common.backend_name, + self.common.backend_type) + + @mock.patch.object(dothill.DotHillClient, 'delete_volume') + def test_delete_volume(self, mock_delete): + not_found_e = exception.DotHillRequestError( + 'The volume was not found on this system.') + mock_delete.side_effect = [not_found_e, exception.DotHillRequestError, + None] + self.assertEqual(None, self.common.delete_volume(test_volume)) + self.assertRaises(exception.Invalid, self.common.delete_volume, + test_volume) + self.assertEqual(None, self.common.delete_volume(test_volume)) + mock_delete.assert_called_with(encoded_volid) + + @mock.patch.object(dothill.DotHillClient, 'copy_volume') + @mock.patch.object(dothill.DotHillClient, 'backend_stats') + def test_create_cloned_volume(self, mock_stats, mock_copy): + mock_stats.side_effect = [stats_low_space, stats_large_space, + stats_large_space] + + self.assertRaises(exception.DotHillNotEnoughSpace, + self.common.create_cloned_volume, + dest_volume, detached_volume) + self.assertFalse(mock_copy.called) + + mock_copy.side_effect = [exception.DotHillRequestError, None] + self.assertRaises(exception.Invalid, + self.common.create_cloned_volume, + dest_volume, detached_volume) + + ret = self.common.create_cloned_volume(dest_volume, detached_volume) + self.assertEqual(None, ret) + + mock_copy.assert_called_with(encoded_volid, + 'vqqqqqqqqqqqqqqqqqqq', + 0, self.common.backend_name) + + @mock.patch.object(dothill.DotHillClient, 'copy_volume') + @mock.patch.object(dothill.DotHillClient, 'backend_stats') + def test_create_volume_from_snapshot(self, mock_stats, mock_copy): + mock_stats.side_effect = [stats_low_space, stats_large_space, + stats_large_space] + + self.assertRaises(exception.DotHillNotEnoughSpace, + self.common.create_volume_from_snapshot, + dest_volume, test_snap) + + mock_copy.side_effect = [exception.DotHillRequestError, None] + self.assertRaises(exception.Invalid, + self.common.create_volume_from_snapshot, + dest_volume, test_snap) + + ret = self.common.create_volume_from_snapshot(dest_volume, test_snap) + self.assertEqual(None, ret) + mock_copy.assert_called_with('sqqqqqqqqqqqqqqqqqqq', + 'vqqqqqqqqqqqqqqqqqqq', + 0, self.common.backend_name) + + @mock.patch.object(dothill.DotHillClient, 'extend_volume') + def test_extend_volume(self, mock_extend): + mock_extend.side_effect = [exception.DotHillRequestError, None] + + self.assertRaises(exception.Invalid, self.common.extend_volume, + test_volume, 20) + ret = self.common.extend_volume(test_volume, 20) + self.assertEqual(None, ret) + mock_extend.assert_called_with(encoded_volid, '10GB') + + @mock.patch.object(dothill.DotHillClient, 'create_snapshot') + def test_create_snapshot(self, mock_create): + mock_create.side_effect = [exception.DotHillRequestError, None] + + self.assertRaises(exception.Invalid, self.common.create_snapshot, + test_snap) + ret = self.common.create_snapshot(test_snap) + self.assertEqual(None, ret) + mock_create.assert_called_with(encoded_volid, 'sqqqqqqqqqqqqqqqqqqq') + + @mock.patch.object(dothill.DotHillClient, 'delete_snapshot') + def test_delete_snapshot(self, mock_delete): + not_found_e = exception.DotHillRequestError( + 'The volume was not found on this system.') + mock_delete.side_effect = [not_found_e, exception.DotHillRequestError, + None] + + self.assertEqual(None, self.common.delete_snapshot(test_snap)) + self.assertRaises(exception.Invalid, self.common.delete_snapshot, + test_snap) + self.assertEqual(None, self.common.delete_snapshot(test_snap)) + mock_delete.assert_called_with('sqqqqqqqqqqqqqqqqqqq') + + @mock.patch.object(dothill.DotHillClient, 'map_volume') + def test_map_volume(self, mock_map): + mock_map.side_effect = [exception.DotHillRequestError, 10] + + self.assertRaises(exception.Invalid, self.common.map_volume, + test_volume, connector, self.connector_element) + lun = self.common.map_volume(test_volume, connector, + self.connector_element) + self.assertEqual(10, lun) + mock_map.assert_called_with(encoded_volid, + connector, self.connector_element) + + @mock.patch.object(dothill.DotHillClient, 'unmap_volume') + def test_unmap_volume(self, mock_unmap): + mock_unmap.side_effect = [exception.DotHillRequestError, None] + + self.assertRaises(exception.Invalid, self.common.unmap_volume, + test_volume, connector, self.connector_element) + ret = self.common.unmap_volume(test_volume, connector, + self.connector_element) + self.assertEqual(None, ret) + mock_unmap.assert_called_with(encoded_volid, connector, + self.connector_element) + + @mock.patch.object(dothill.DotHillClient, 'copy_volume') + @mock.patch.object(dothill.DotHillClient, 'delete_volume') + @mock.patch.object(dothill.DotHillClient, 'modify_volume_name') + def test_retype(self, mock_modify, mock_delete, mock_copy): + mock_copy.side_effect = [exception.DotHillRequestError, None] + self.assertRaises(exception.Invalid, self.common.migrate_volume, + test_retype_volume, test_host) + ret = self.common.migrate_volume(test_retype_volume, test_host) + self.assertEqual((True, None), ret) + ret = self.common.migrate_volume(test_retype_volume, + {'capabilities': {}}) + self.assertEqual((False, None), ret) + + @mock.patch.object(dothill_common.DotHillCommon, '_get_vol_name') + @mock.patch.object(dothill.DotHillClient, 'modify_volume_name') + def test_manage_existing(self, mock_modify, mock_volume): + existing_ref = {'source-name': 'xxxx'} + mock_modify.side_effect = [exception.DotHillRequestError, None] + self.assertRaises(exception.Invalid, self.common.manage_existing, + test_volume, existing_ref) + ret = self.common.manage_existing(test_volume, existing_ref) + self.assertEqual(None, ret) + + @mock.patch.object(dothill.DotHillClient, 'get_volume_size') + def test_manage_existing_get_size(self, mock_volume): + existing_ref = {'source-name': 'xxxx'} + mock_volume.side_effect = [exception.DotHillRequestError, 1] + self.assertRaises(exception.Invalid, + self.common.manage_existing_get_size, + None, existing_ref) + ret = self.common.manage_existing_get_size(None, existing_ref) + self.assertEqual(1, ret) + + +class TestISCSIDotHillCommon(TestFCDotHillCommon): + def setUp(self): + super(TestISCSIDotHillCommon, self).setUp() + self.connector_element = 'initiator' + + +class TestDotHillFC(test.TestCase): + @mock.patch.object(dothill_common.DotHillCommon, 'do_setup') + def setUp(self, mock_setup): + super(TestDotHillFC, self).setUp() + self.vendor_name = 'DotHill' + + mock_setup.return_value = True + + def fake_init(self, *args, **kwargs): + super(dothill_fc.DotHillFCDriver, self).__init__() + self.common = None + self.configuration = FakeConfiguration1() + self.lookup_service = fczm_utils.create_lookup_service() + + dothill_fc.DotHillFCDriver.__init__ = fake_init + self.driver = dothill_fc.DotHillFCDriver() + self.driver.do_setup(None) + + def _test_with_mock(self, mock, method, args, expected=None): + func = getattr(self.driver, method) + mock.side_effect = [exception.Invalid(), None] + self.assertRaises(exception.Invalid, func, *args) + self.assertEqual(expected, func(*args)) + + @mock.patch.object(dothill_common.DotHillCommon, 'create_volume') + def test_create_volume(self, mock_create): + self._test_with_mock(mock_create, 'create_volume', [None], + {'metadata': None}) + + @mock.patch.object(dothill_common.DotHillCommon, + 'create_cloned_volume') + def test_create_cloned_volume(self, mock_create): + self._test_with_mock(mock_create, 'create_cloned_volume', [None, None], + {'metadata': None}) + + @mock.patch.object(dothill_common.DotHillCommon, + 'create_volume_from_snapshot') + def test_create_volume_from_snapshot(self, mock_create): + self._test_with_mock(mock_create, 'create_volume_from_snapshot', + [None, None], None) + + @mock.patch.object(dothill_common.DotHillCommon, 'delete_volume') + def test_delete_volume(self, mock_delete): + self._test_with_mock(mock_delete, 'delete_volume', [None]) + + @mock.patch.object(dothill_common.DotHillCommon, 'create_snapshot') + def test_create_snapshot(self, mock_create): + self._test_with_mock(mock_create, 'create_snapshot', [None]) + + @mock.patch.object(dothill_common.DotHillCommon, 'delete_snapshot') + def test_delete_snapshot(self, mock_delete): + self._test_with_mock(mock_delete, 'delete_snapshot', [None]) + + @mock.patch.object(dothill_common.DotHillCommon, 'extend_volume') + def test_extend_volume(self, mock_extend): + self._test_with_mock(mock_extend, 'extend_volume', [None, 10]) + + @mock.patch.object(dothill_common.DotHillCommon, 'client_logout') + @mock.patch.object(dothill_common.DotHillCommon, + 'get_active_fc_target_ports') + @mock.patch.object(dothill_common.DotHillCommon, 'map_volume') + @mock.patch.object(dothill_common.DotHillCommon, 'client_login') + def test_initialize_connection(self, mock_login, mock_map, mock_ports, + mock_logout): + mock_login.return_value = None + mock_logout.return_value = None + mock_map.side_effect = [exception.Invalid, 1] + mock_ports.side_effect = [['id1']] + + self.assertRaises(exception.Invalid, + self.driver.initialize_connection, test_volume, + connector) + mock_map.assert_called_with(test_volume, connector, 'wwpns') + + ret = self.driver.initialize_connection(test_volume, connector) + self.assertEqual({'driver_volume_type': 'fibre_channel', + 'data': {'initiator_target_map': { + '111111111111111': ['id1'], + '111111111111112': ['id1']}, + 'target_wwn': ['id1'], + 'target_lun': 1, + 'target_discovered': True}}, ret) + + @mock.patch.object(dothill_common.DotHillCommon, 'unmap_volume') + @mock.patch.object(dothill.DotHillClient, 'list_luns_for_host') + def test_terminate_connection(self, mock_list, mock_unmap): + mock_unmap.side_effect = [exception.Invalid, 1] + mock_list.side_effect = ['yes'] + actual = {'driver_volume_type': 'fibre_channel', 'data': {}} + self.assertRaises(exception.Invalid, + self.driver.terminate_connection, test_volume, + connector) + mock_unmap.assert_called_with(test_volume, connector, 'wwpns') + + ret = self.driver.terminate_connection(test_volume, connector) + self.assertEqual(actual, ret) + + @mock.patch.object(dothill_common.DotHillCommon, 'get_volume_stats') + def test_get_volume_stats(self, mock_stats): + stats = {'storage_protocol': None, + 'driver_version': self.driver.VERSION, + 'volume_backend_name': None, + 'vendor_name': self.vendor_name, + 'pools': [{'free_capacity_gb': 90, + 'reserved_percentage': 0, + 'total_capacity_gb': 100, + 'QoS_support': False, + 'location_info': 'xx:xx:xx:xx', + 'pool_name': 'x'}]} + mock_stats.side_effect = [exception.Invalid, stats, stats] + + self.assertRaises(exception.Invalid, self.driver.get_volume_stats, + False) + ret = self.driver.get_volume_stats(False) + self.assertEqual(stats, ret) + + ret = self.driver.get_volume_stats(True) + self.assertEqual(stats, ret) + mock_stats.assert_called_with(True) + + @mock.patch.object(dothill_common.DotHillCommon, 'retype') + def test_retype(self, mock_retype): + mock_retype.side_effect = [exception.Invalid, True, False] + args = [None, None, None, None, None] + self.assertRaises(exception.Invalid, self.driver.retype, *args) + self.assertEqual(True, self.driver.retype(*args)) + self.assertEqual(False, self.driver.retype(*args)) + + @mock.patch.object(dothill_common.DotHillCommon, 'manage_existing') + def test_manage_existing(self, mock_manage_existing): + self._test_with_mock(mock_manage_existing, 'manage_existing', + [None, None]) + + @mock.patch.object(dothill_common.DotHillCommon, + 'manage_existing_get_size') + def test_manage_size(self, mock_manage_size): + mock_manage_size.side_effect = [exception.Invalid, 1] + self.assertRaises(exception.Invalid, + self.driver.manage_existing_get_size, + None, None) + self.assertEqual(1, self.driver.manage_existing_get_size(None, None)) + + +class TestDotHillISCSI(TestDotHillFC): + @mock.patch.object(dothill_common.DotHillCommon, 'do_setup') + def setUp(self, mock_setup): + super(TestDotHillISCSI, self).setUp() + self.vendor_name = 'DotHill' + mock_setup.return_value = True + + def fake_init(self, *args, **kwargs): + super(dothill_iscsi.DotHillISCSIDriver, self).__init__() + self.common = None + self.configuration = FakeConfiguration2() + self.iscsi_ips = ['10.0.0.11'] + + dothill_iscsi.DotHillISCSIDriver.__init__ = fake_init + self.driver = dothill_iscsi.DotHillISCSIDriver() + self.driver.do_setup(None) + + @mock.patch.object(dothill_common.DotHillCommon, 'client_logout') + @mock.patch.object(dothill_common.DotHillCommon, + 'get_active_iscsi_target_portals') + @mock.patch.object(dothill_common.DotHillCommon, + 'get_active_iscsi_target_iqns') + @mock.patch.object(dothill_common.DotHillCommon, 'map_volume') + @mock.patch.object(dothill_common.DotHillCommon, 'client_login') + def test_initialize_connection(self, mock_login, mock_map, mock_iqns, + mock_portals, mock_logout): + mock_login.return_value = None + mock_logout.return_value = None + mock_map.side_effect = [exception.Invalid, 1] + self.driver.iscsi_ips = ['10.0.0.11'] + self.driver.initialize_iscsi_ports() + mock_iqns.side_effect = [['id2']] + mock_portals.side_effect = {'10.0.0.11': 'Up', '10.0.0.12': 'Up'} + + self.assertRaises(exception.Invalid, + self.driver.initialize_connection, test_volume, + connector) + mock_map.assert_called_with(test_volume, connector, 'initiator') + + ret = self.driver.initialize_connection(test_volume, connector) + self.assertEqual({'driver_volume_type': 'iscsi', + 'data': {'target_iqn': 'id2', + 'target_lun': 1, + 'target_discovered': True, + 'target_portal': '10.0.0.11:3260'}}, ret) + + @mock.patch.object(dothill_common.DotHillCommon, 'unmap_volume') + def test_terminate_connection(self, mock_unmap): + mock_unmap.side_effect = [exception.Invalid, 1] + + self.assertRaises(exception.Invalid, + self.driver.terminate_connection, test_volume, + connector) + mock_unmap.assert_called_with(test_volume, connector, 'initiator') + + ret = self.driver.terminate_connection(test_volume, connector) + self.assertEqual(None, ret) diff --git a/cinder/volume/drivers/dothill/__init__.py b/cinder/volume/drivers/dothill/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinder/volume/drivers/dothill/dothill_client.py b/cinder/volume/drivers/dothill/dothill_client.py new file mode 100644 index 000000000..b5975dc8f --- /dev/null +++ b/cinder/volume/drivers/dothill/dothill_client.py @@ -0,0 +1,336 @@ +# Copyright 2014 Objectif Libre +# Copyright 2015 DotHill Systems +# +# 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 hashlib import md5 +import math +import time +import urllib2 + +from lxml import etree +from oslo_log import log as logging + +from cinder import exception +from cinder.i18n import _LE + +LOG = logging.getLogger(__name__) + + +class DotHillClient(object): + def __init__(self, host, login, password, protocol): + self._login = login + self._password = password + self._base_url = "%s://%s/api" % (protocol, host) + self._session_key = None + + def _get_auth_token(self, xml): + """Parse an XML authentication reply to extract the session key.""" + self._session_key = None + tree = etree.XML(xml) + if tree.findtext(".//PROPERTY[@name='response-type']") == "success": + self._session_key = tree.findtext(".//PROPERTY[@name='response']") + + def login(self): + """Authenticates the service on the device.""" + hash_ = md5("%s_%s" % (self._login, self._password)) + digest = hash_.hexdigest() + + url = self._base_url + "/login/" + digest + try: + xml = urllib2.urlopen(url).read() + except urllib2.URLError: + raise exception.DotHillConnectionError + + self._get_auth_token(xml) + + if self._session_key is None: + raise exception.DotHillAuthenticationError + + def _assert_response_ok(self, tree): + """Parses the XML returned by the device to check the return code. + + Raises a DotHillRequestError error if the return code is not 0. + """ + return_code = tree.findtext(".//PROPERTY[@name='return-code']") + if return_code and return_code != '0': + raise exception.DotHillRequestError( + message=tree.findtext(".//PROPERTY[@name='response']")) + elif not return_code: + raise exception.DotHillRequestError(message="No status found") + + def _build_request_url(self, path, *args, **kargs): + url = self._base_url + path + if kargs: + url += '/' + '/'.join(["%s/%s" % (k.replace('_', '-'), v) + for (k, v) in kargs.items()]) + if args: + url += '/' + '/'.join(args) + + return url + + def _request(self, path, *args, **kargs): + """Performs an HTTP request on the device. + + Raises a DotHillRequestError if the device returned but the status is + not 0. The device error message will be used in the exception message. + + If the status is OK, returns the XML data for further processing. + """ + + url = self._build_request_url(path, *args, **kargs) + headers = {'dataType': 'api', 'sessionKey': self._session_key} + req = urllib2.Request(url, headers=headers) + try: + xml = urllib2.urlopen(req).read() + tree = etree.XML(xml) + except Exception: + raise exception.DotHillConnectionError + + if path == "/show/volumecopy-status": + return tree + self._assert_response_ok(tree) + return tree + + def logout(self): + url = self._base_url + '/exit' + try: + urllib2.urlopen(url) + return True + except Exception: + return False + + def create_volume(self, name, size, backend_name, backend_type): + # NOTE: size is in this format: [0-9]+GB + path_dict = {'size': size} + if backend_type == "linear": + path_dict['vdisk'] = backend_name + else: + path_dict['pool'] = backend_name + + self._request("/create/volume", name, **path_dict) + return None + + def delete_volume(self, name): + self._request("/delete/volumes", name) + + def extend_volume(self, name, added_size): + self._request("/expand/volume", name, size=added_size) + + def create_snapshot(self, volume_name, snap_name): + self._request("/create/snapshots", snap_name, volumes=volume_name) + + def delete_snapshot(self, snap_name): + self._request("/delete/snapshot", "cleanup", snap_name) + + def backend_exists(self, backend_name, backend_type): + try: + if backend_type == "linear": + path = "/show/vdisks" + else: + path = "/show/pools" + self._request(path, backend_name) + return True + except exception.DotHillRequestError: + return False + + def _get_size(self, size): + return int(math.ceil(float(size) * 512 / (10 ** 9))) + + def backend_stats(self, backend_name, backend_type): + stats = {'free_capacity_gb': 0, + 'total_capacity_gb': 0} + prop_list = [] + if backend_type == "linear": + path = "/show/vdisks" + prop_list = ["size-numeric", "freespace-numeric"] + else: + path = "/show/pools" + prop_list = ["total-size-numeric", "total-avail-numeric"] + tree = self._request(path, backend_name) + + size = tree.findtext(".//PROPERTY[@name='%s']" % prop_list[0]) + if size: + stats['total_capacity_gb'] = self._get_size(size) + + size = tree.findtext(".//PROPERTY[@name='%s']" % prop_list[1]) + if size: + stats['free_capacity_gb'] = self._get_size(size) + return stats + + def list_luns_for_host(self, host): + tree = self._request("/show/host-maps", host) + return [int(prop.text) for prop in tree.xpath( + "//PROPERTY[@name='lun']")] + + def _get_first_available_lun_for_host(self, host): + luns = self.list_luns_for_host(host) + lun = 1 + while True: + if lun not in luns: + return lun + lun += 1 + + def map_volume(self, volume_name, connector, connector_element): + if connector_element == 'wwpns': + lun = self._get_first_available_lun_for_host(connector['wwpns'][0]) + host = ",".join(connector['wwpns']) + else: + host = connector['initiator'] + host_status = self._check_host(host) + if host_status != 0: + hostname = self._safe_hostname(connector['host']) + self._request("/create/host", hostname, id=host) + lun = self._get_first_available_lun_for_host(host) + + self._request("/map/volume", + volume_name, + lun=str(lun), + host=host, + access="rw") + return lun + + def unmap_volume(self, volume_name, connector, connector_element): + if connector_element == 'wwpns': + host = ",".join(connector['wwpns']) + else: + host = connector['initiator'] + self._request("/unmap/volume", volume_name, host=host) + + def get_active_target_ports(self): + ports = [] + tree = self._request("/show/ports") + + for obj in tree.xpath("//OBJECT[@basetype='port']"): + port = {prop.get('name'): prop.text + for prop in obj.iter("PROPERTY") + if prop.get('name') in + ["port-type", "target-id", "status"]} + if port['status'] == 'Up': + ports.append(port) + return ports + + def get_active_fc_target_ports(self): + return [port['target-id'] for port in self.get_active_target_ports() + if port['port-type'] == "FC"] + + def get_active_iscsi_target_iqns(self): + return [port['target-id'] for port in self.get_active_target_ports() + if port['port-type'] == "iSCSI"] + + def copy_volume(self, src_name, dest_name, same_bknd, dest_bknd_name): + self._request("/volumecopy", + dest_name, + dest_vdisk=dest_bknd_name, + source_volume=src_name, + prompt='yes') + + if same_bknd == 0: + return + + count = 0 + while True: + tree = self._request("/show/volumecopy-status") + return_code = tree.findtext(".//PROPERTY[@name='return-code']") + + if return_code == '0': + status = tree.findtext(".//PROPERTY[@name='progress']") + progress = False + if status: + progress = True + LOG.debug("Volume copy is in progress: %s", status) + if not progress: + LOG.debug("Volume copy completed: %s", status) + break + else: + if count >= 5: + LOG.error(_LE('Error in copying volume: %s'), src_name) + raise exception.DotHillRequestError + break + time.sleep(1) + count += 1 + + time.sleep(5) + + def _check_host(self, host): + host_status = -1 + tree = self._request("/show/hosts") + for prop in tree.xpath("//PROPERTY[@name='host-id' and text()='%s']" + % host): + host_status = 0 + return host_status + + def _safe_hostname(self, hostname): + """DotHill hostname restrictions. + + A host name cannot include " , \ in linear and " , < > \ in realstor + and can have a max of 15 bytes in linear and 32 bytes in realstor. + """ + for ch in [',', '"', '\\', '<', '>']: + if ch in hostname: + hostname = hostname.replace(ch, '') + index = len(hostname) + if index > 15: + index = 15 + return hostname[:index] + + def get_active_iscsi_target_portals(self, backend_type): + # This function returns {'ip': status,} + portals = {} + prop = "" + tree = self._request("/show/ports") + if backend_type == "linear": + prop = "primary-ip-address" + else: + prop = "ip-address" + + iscsi_ips = [ip.text for ip in tree.xpath( + "//PROPERTY[@name='%s']" % prop)] + if not iscsi_ips: + return portals + for index, port_type in enumerate(tree.xpath( + "//PROPERTY[@name='port-type' and text()='iSCSI']")): + status = port_type.getparent().findtext("PROPERTY[@name='status']") + if status == 'Up': + portals[iscsi_ips[index]] = status + return portals + + def get_chap_record(self, initiator_name): + tree = self._request("/show/chap-records") + for prop in tree.xpath("//PROPERTY[@name='initiator-name' and " + "text()='%s']" % initiator_name): + chap_secret = prop.getparent().findtext("PROPERTY[@name='initiator" + "-secret']") + return chap_secret + + def create_chap_record(self, initiator_name, chap_secret): + self._request("/create/chap-record", + name=initiator_name, + secret=chap_secret) + + def get_serial_number(self): + tree = self._request("/show/system") + return tree.findtext(".//PROPERTY[@name='midplane-serial-number']") + + def get_owner_info(self, backend_name): + tree = self._request("/show/vdisks", backend_name) + return tree.findtext(".//PROPERTY[@name='owner']") + + def modify_volume_name(self, old_name, new_name): + self._request("/set/volume", old_name, name=new_name) + + def get_volume_size(self, volume_name): + tree = self._request("/show/volumes", volume_name) + size = tree.findtext(".//PROPERTY[@name='size-numeric']") + return self._get_size(size) diff --git a/cinder/volume/drivers/dothill/dothill_common.py b/cinder/volume/drivers/dothill/dothill_common.py new file mode 100644 index 000000000..271317705 --- /dev/null +++ b/cinder/volume/drivers/dothill/dothill_common.py @@ -0,0 +1,542 @@ +# Copyright 2014 Objectif Libre +# Copyright 2015 DotHill Systems +# +# 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. +# +""" +Volume driver common utilities for DotHill Storage array +""" + +import base64 +import six +import uuid + +from oslo_config import cfg +from oslo_log import log as logging + +from cinder import exception +from cinder.i18n import _, _LE +from cinder.volume.drivers.dothill import dothill_client as dothill + +LOG = logging.getLogger(__name__) + +common_opt = [ + cfg.StrOpt('dothill_backend_name', + default='OpenStack', + help="VDisk or Pool name to use for volume creation."), + cfg.StrOpt('dothill_backend_type', + choices=['linear', 'realstor'], + help="linear (for VDisk) or realstor (for Pool)."), + cfg.StrOpt('dothill_wbi_protocol', + choices=['http', 'https'], + help="DotHill web interface protocol."), +] + +iscsi_opt = [ + cfg.ListOpt('dothill_iscsi_ips', + default=[], + help="List of comma separated target iSCSI IP addresses."), +] + +CONF = cfg.CONF +CONF.register_opts(common_opt) +CONF.register_opts(iscsi_opt) + + +class DotHillCommon(object): + VERSION = "1.0" + + stats = {} + + def __init__(self, config): + self.config = config + self.vendor_name = "DotHill" + self.backend_name = self.config.dothill_backend_name + self.backend_type = self.config.dothill_backend_type + self.client = dothill.DotHillClient(self.config.san_ip, + self.config.san_login, + self.config.san_password, + self.config.dothill_wbi_protocol) + + def get_version(self): + return self.VERSION + + def do_setup(self, context): + self.client_login() + self._validate_backend() + if (self.backend_type == "linear" or + (self.backend_type == "realstor" and + self.backend_name not in ['A', 'B'])): + self._get_owner_info(self.backend_name) + self._get_serial_number() + self.client_logout() + + def client_login(self): + LOG.debug("Connecting to %s Array.", self.vendor_name) + try: + self.client.login() + except exception.DotHillConnectionError as ex: + msg = _("Failed to connect to %(vendor_name)s Array %(host)s: " + "%(err)s") % {'vendor_name': self.vendor_name, + 'host': self.config.san_ip, + 'err': six.text_type(ex)} + LOG.error(msg) + raise exception.DotHillConnectionError(message=msg) + except exception.DotHillAuthenticationError: + msg = _("Failed to log on %s Array " + "(invalid login?).") % self.vendor_name + LOG.error(msg) + raise exception.DotHillAuthenticationError(message=msg) + + def _get_serial_number(self): + self.serialNumber = self.client.get_serial_number() + + def _get_owner_info(self, backend_name): + self.owner = self.client.get_owner_info(backend_name) + + def _validate_backend(self): + if not self.client.backend_exists(self.backend_name, + self.backend_type): + self.client_logout() + raise exception.DotHillInvalidBackend(backend=self.backend_name) + + def client_logout(self): + self.client.logout() + LOG.debug("Disconnected from %s Array.", self.vendor_name) + + def _get_vol_name(self, volume_id): + volume_name = self._encode_name(volume_id) + return "v%s" % volume_name + + def _get_snap_name(self, snapshot_id): + snapshot_name = self._encode_name(snapshot_id) + return "s%s" % snapshot_name + + def _encode_name(self, name): + """Get converted DotHill volume name. + + Converts the openstack volume id from + fceec30e-98bc-4ce5-85ff-d7309cc17cc2 + to + v_O7DDpi8TOWF_9cwnMF + + We convert the 128(32*4) bits of the uuid into a 24character long + base64 encoded string. This still exceeds the limit of 20 characters + so we truncate the name later. + """ + uuid_str = name.replace("-", "") + vol_uuid = uuid.UUID('urn:uuid:%s' % uuid_str) + vol_encoded = base64.b64encode(vol_uuid.bytes) + vol_encoded = vol_encoded.replace('=', '') + + # + is not a valid character for DotHill + vol_encoded = vol_encoded.replace('+', '.') + # since we use http URLs to send paramters, '/' is not an acceptable + # parameter + vol_encoded = vol_encoded.replace('/', '_') + + # NOTE:we limit the size to 20 characters since the array + # doesn't support more than that for now. Duplicates should happen very + # rarely. + # We return 19 chars here because the _get_{vol,snap}_name functions + # prepend a character + return vol_encoded[:19] + + def check_flags(self, options, required_flags): + for flag in required_flags: + if not getattr(options, flag, None): + msg = _('%s configuration option is not set.') % flag + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + + def create_volume(self, volume): + self.client_login() + # Use base64 to encode the volume name (UUID is too long for DotHill) + volume_name = self._get_vol_name(volume['id']) + volume_size = "%dGB" % volume['size'] + LOG.debug("Create Volume having display_name: %(display_name)s " + "name: %(name)s id: %(id)s size: %(size)s", + {'display_name': volume['display_name'], + 'name': volume['name'], + 'id': volume_name, + 'size': volume_size, }) + try: + metadata = self.client.create_volume(volume_name, + volume_size, + self.backend_name, + self.backend_type) + return metadata + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Creation of volume %s failed."), volume['id']) + raise exception.Invalid(ex) + + finally: + self.client_logout() + + def _assert_enough_space_for_copy(self, volume_size): + """The DotHill creates a snap pool before trying to copy the volume. + The pool is 5.27GB or 20% of the volume size, whichever is larger. + + Verify that we have enough space for the pool and then copy + """ + pool_size = max(volume_size * 0.2, 5.27) + required_size = pool_size + volume_size + + if required_size > self.stats['pools'][0]['free_capacity_gb']: + raise exception.DotHillNotEnoughSpace(backend=self.backend_name) + + def _assert_source_detached(self, volume): + """The DotHill requires a volume to be dettached to clone it. + + Make sure that the volume is not in use when trying to copy it. + """ + if (volume['status'] != "available" or + volume['attach_status'] == "attached"): + LOG.error(_LE("Volume must be detached for clone operation.")) + raise exception.VolumeAttached(volume_id=volume['id']) + + def create_cloned_volume(self, volume, src_vref): + if self.backend_type == "realstor" and self.backend_name in ["A", "B"]: + msg = _("Create volume from volume(clone) does not have support " + "for virtual pool A and B.") + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + self.get_volume_stats(True) + self._assert_enough_space_for_copy(volume['size']) + self._assert_source_detached(src_vref) + LOG.debug("Cloning Volume %(source_id)s to (%(dest_id)s)", + {'source_id': volume['source_volid'], + 'dest_id': volume['id'], }) + + if src_vref['name_id']: + orig_name = self._get_vol_name(src_vref['name_id']) + else: + orig_name = self._get_vol_name(volume['source_volid']) + dest_name = self._get_vol_name(volume['id']) + + self.client_login() + try: + self.client.copy_volume(orig_name, dest_name, 0, self.backend_name) + return None + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Cloning of volume %s failed."), + volume['source_volid']) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def create_volume_from_snapshot(self, volume, snapshot): + if self.backend_type == "realstor" and self.backend_name in ["A", "B"]: + msg = _('Create volume from snapshot does not have support ' + 'for virtual pool A and B.') + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + self.get_volume_stats(True) + self._assert_enough_space_for_copy(volume['size']) + LOG.debug("Creating Volume from snapshot %(source_id)s to " + "(%(dest_id)s)", {'source_id': snapshot['id'], + 'dest_id': volume['id'], }) + + orig_name = self._get_snap_name(snapshot['id']) + dest_name = self._get_vol_name(volume['id']) + self.client_login() + try: + self.client.copy_volume(orig_name, dest_name, 0, self.backend_name) + return None + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Create volume failed from snapshot: %s"), + snapshot['id']) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def delete_volume(self, volume): + LOG.debug("Deleting Volume: %s", volume['id']) + if volume['name_id']: + volume_name = self._get_vol_name(volume['name_id']) + else: + volume_name = self._get_vol_name(volume['id']) + + self.client_login() + try: + self.client.delete_volume(volume_name) + except exception.DotHillRequestError as ex: + # if the volume wasn't found, ignore the error + if 'The volume was not found on this system.' in ex: + return + LOG.exception(_LE("Deletion of volume %s failed."), volume['id']) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def get_volume_stats(self, refresh): + if refresh: + self.client_login() + try: + self._update_volume_stats() + finally: + self.client_logout() + return self.stats + + def _update_volume_stats(self): + # storage_protocol and volume_backend_name are + # set in the child classes + stats = {'driver_version': self.VERSION, + 'storage_protocol': None, + 'vendor_name': self.vendor_name, + 'volume_backend_name': None, + 'pools': []} + + pool = {'QoS_support': False} + try: + src_type = "%sVolumeDriver" % self.vendor_name + backend_stats = self.client.backend_stats(self.backend_name, + self.backend_type) + pool.update(backend_stats) + if (self.backend_type == "linear" or + (self.backend_type == "realstor" and + self.backend_name not in ['A', 'B'])): + pool['location_info'] = ('%s:%s:%s:%s' % + (src_type, + self.serialNumber, + self.backend_name, + self.owner)) + pool['pool_name'] = self.backend_name + except exception.DotHillRequestError: + err = (_("Unable to get stats for backend_name: %s") % + self.backend_name) + LOG.exception(err) + raise exception.Invalid(reason=err) + + stats['pools'].append(pool) + self.stats = stats + + def _assert_connector_ok(self, connector, connector_element): + if not connector[connector_element]: + msg = _("Connector does not provide: %s") % connector_element + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + + def map_volume(self, volume, connector, connector_element): + self._assert_connector_ok(connector, connector_element) + if volume['name_id']: + volume_name = self._get_vol_name(volume['name_id']) + else: + volume_name = self._get_vol_name(volume['id']) + try: + data = self.client.map_volume(volume_name, + connector, + connector_element) + return data + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error mapping volume: %s"), volume_name) + raise exception.Invalid(ex) + + def unmap_volume(self, volume, connector, connector_element): + self._assert_connector_ok(connector, connector_element) + if volume['name_id']: + volume_name = self._get_vol_name(volume['name_id']) + else: + volume_name = self._get_vol_name(volume['id']) + + self.client_login() + try: + self.client.unmap_volume(volume_name, + connector, + connector_element) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error unmapping volume: %s"), volume_name) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def get_active_fc_target_ports(self): + try: + return self.client.get_active_fc_target_ports() + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error getting active FC target ports.")) + raise exception.Invalid(ex) + + def get_active_iscsi_target_iqns(self): + try: + return self.client.get_active_iscsi_target_iqns() + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error getting active ISCSI target iqns.")) + raise exception.Invalid(ex) + + def get_active_iscsi_target_portals(self): + try: + return self.client.get_active_iscsi_target_portals( + self.backend_type) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error getting active ISCSI target portals.")) + raise exception.Invalid(ex) + + def create_snapshot(self, snapshot): + LOG.debug("Creating snapshot (%(snap_id)s) from %(volume_id)s)", + {'snap_id': snapshot['id'], + 'volume_id': snapshot['volume_id'], }) + if snapshot['volume']['name_id']: + vol_name = self._get_vol_name(snapshot['volume']['name_id']) + else: + vol_name = self._get_vol_name(snapshot['volume_id']) + snap_name = self._get_snap_name(snapshot['id']) + + self.client_login() + try: + self.client.create_snapshot(vol_name, snap_name) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Creation of snapshot failed for volume: %s"), + snapshot['volume_id']) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def delete_snapshot(self, snapshot): + snap_name = self._get_snap_name(snapshot['id']) + LOG.debug("Deleting snapshot (%s)", snapshot['id']) + + self.client_login() + try: + self.client.delete_snapshot(snap_name) + except exception.DotHillRequestError as ex: + # if the volume wasn't found, ignore the error + if 'The volume was not found on this system.' in ex: + return + LOG.exception(_LE("Deleting snapshot %s failed"), snapshot['id']) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def extend_volume(self, volume, new_size): + if volume['name_id']: + volume_name = self._get_vol_name(volume['name_id']) + else: + volume_name = self._get_vol_name(volume['id']) + old_size = volume['size'] + growth_size = int(new_size) - old_size + LOG.debug("Extending Volume %(volume_name)s from %(old_size)s to " + "%(new_size)s, by %(growth_size)s GB.", + {'volume_name': volume_name, + 'old_size': old_size, + 'new_size': new_size, + 'growth_size': growth_size, }) + self.client_login() + try: + self.client.extend_volume(volume_name, "%dGB" % growth_size) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Extension of volume %s failed."), volume['id']) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def get_chap_record(self, initiator_name): + try: + return self.client.get_chap_record(initiator_name) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error getting chap record.")) + raise exception.Invalid(ex) + + def create_chap_record(self, initiator_name, chap_secret): + try: + self.client.create_chap_record(initiator_name, chap_secret) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error creating chap record.")) + raise exception.Invalid(ex) + + def migrate_volume(self, volume, host): + """Migrate directly if source and dest are managed by same storage. + + :param volume: A dictionary describing the volume to migrate + :param host: A dictionary describing the host to migrate to, where + host['host'] is its name, and host['capabilities'] is a + dictionary of its reported capabilities. + :returns (False, None) if the driver does not support migration, + (True, None) if successful + + """ + false_ret = (False, None) + if volume['attach_status'] == "attached": + return false_ret + if 'location_info' not in host['capabilities']: + return false_ret + info = host['capabilities']['location_info'] + try: + (dest_type, dest_id, + dest_back_name, dest_owner) = info.split(':') + except ValueError: + return false_ret + + if not (dest_type == 'DotHillVolumeDriver' and + dest_id == self.serialNumber and + dest_owner == self.owner): + return false_ret + if volume['name_id']: + source_name = self._get_vol_name(volume['name_id']) + else: + source_name = self._get_vol_name(volume['id']) + # DotHill Array does not support duplicate names + dest_name = "m%s" % source_name[1:] + + self.client_login() + try: + self.client.copy_volume(source_name, dest_name, 1, dest_back_name) + self.client.delete_volume(source_name) + self.client.modify_volume_name(dest_name, source_name) + return (True, None) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error migrating volume: %s"), source_name) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def retype(self, volume, new_type, diff, host): + ret = self.migrate_volume(volume, host) + return ret[0] + + def manage_existing(self, volume, existing_ref): + """Manage an existing non-openstack DotHill volume + + existing_ref is a dictionary of the form: + {'source-name': } + """ + target_vol_name = existing_ref['source-name'] + modify_target_vol_name = self._get_vol_name(volume['id']) + + self.client_login() + try: + self.client.modify_volume_name(target_vol_name, + modify_target_vol_name) + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error manage existing volume.")) + raise exception.Invalid(ex) + finally: + self.client_logout() + + def manage_existing_get_size(self, volume, existing_ref): + """Return size of volume to be managed by manage_existing. + + existing_ref is a dictionary of the form: + {'source-name': } + """ + target_vol_name = existing_ref['source-name'] + + self.client_login() + try: + size = self.client.get_volume_size(target_vol_name) + return size + except exception.DotHillRequestError as ex: + LOG.exception(_LE("Error manage existing get volume size.")) + raise exception.Invalid(ex) + finally: + self.client_logout() diff --git a/cinder/volume/drivers/dothill/dothill_fc.py b/cinder/volume/drivers/dothill/dothill_fc.py new file mode 100644 index 000000000..840c43542 --- /dev/null +++ b/cinder/volume/drivers/dothill/dothill_fc.py @@ -0,0 +1,172 @@ +# Copyright 2014 Objectif Libre +# Copyright 2015 DotHill Systems +# +# 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 as logging + +import cinder.volume.driver +from cinder.volume.drivers.dothill import dothill_common +from cinder.volume.drivers.san import san +from cinder.zonemanager import utils as fczm_utils + +LOG = logging.getLogger(__name__) + + +class DotHillFCDriver(cinder.volume.driver.FibreChannelDriver): + """Openstack Fibre Channel cinder drivers for DotHill Arrays. + + Version history: + 0.1 - Base version developed for HPMSA FC drivers: + "https://github.com/openstack/cinder/tree/stable/juno/ + cinder/volume/drivers/san/hp" + 1.0 - Version developed for DotHill arrays with the following + modifications: + - added support for v3 API(realstor feature) + - added support for retype volume + - added support for manage/unmanage volume + - added initiator target mapping in FC zoning + - added https support + + """ + + VERSION = "1.0" + + def __init__(self, *args, **kwargs): + super(DotHillFCDriver, self).__init__(*args, **kwargs) + self.common = None + self.configuration.append_config_values(dothill_common.common_opt) + self.configuration.append_config_values(san.san_opts) + self.lookup_service = fczm_utils.create_lookup_service() + + def _init_common(self): + return dothill_common.DotHillCommon(self.configuration) + + def _check_flags(self): + required_flags = ['san_ip', 'san_login', 'san_password'] + self.common.check_flags(self.configuration, required_flags) + + def do_setup(self, context): + self.common = self._init_common() + self._check_flags() + self.common.do_setup(context) + + def check_for_setup_error(self): + self._check_flags() + + def create_volume(self, volume): + return {'metadata': self.common.create_volume(volume)} + + def create_volume_from_snapshot(self, volume, src_vref): + self.common.create_volume_from_snapshot(volume, src_vref) + + def create_cloned_volume(self, volume, src_vref): + return {'metadata': self.common.create_cloned_volume(volume, src_vref)} + + def delete_volume(self, volume): + self.common.delete_volume(volume) + + @fczm_utils.AddFCZone + def initialize_connection(self, volume, connector): + self.common.client_login() + try: + data = {} + data['target_lun'] = self.common.map_volume(volume, + connector, + 'wwpns') + + ports, init_targ_map = self.get_init_targ_map(connector) + data['target_discovered'] = True + data['target_wwn'] = ports + data['initiator_target_map'] = init_targ_map + info = {'driver_volume_type': 'fibre_channel', + 'data': data} + return info + finally: + self.common.client_logout() + + @fczm_utils.RemoveFCZone + def terminate_connection(self, volume, connector, **kwargs): + self.common.unmap_volume(volume, connector, 'wwpns') + info = {'driver_volume_type': 'fibre_channel', 'data': {}} + if not self.common.client.list_luns_for_host(connector['wwpns'][0]): + ports, init_targ_map = self.get_init_targ_map(connector) + info['data'] = {'target_wwn': ports, + 'initiator_target_map': init_targ_map} + return info + + def get_init_targ_map(self, connector): + init_targ_map = {} + target_wwns = [] + ports = self.common.get_active_fc_target_ports() + if self.lookup_service is not None: + dev_map = self.lookup_service.get_device_mapping_from_network( + connector['wwpns'], + ports) + for fabric_name in dev_map: + fabric = dev_map[fabric_name] + target_wwns += fabric['target_port_wwn_list'] + for initiator in fabric['initiator_port_wwn_list']: + if initiator not in init_targ_map: + init_targ_map[initiator] = [] + init_targ_map[initiator] += fabric['target_port_wwn_list'] + init_targ_map[initiator] = list(set( + init_targ_map[initiator])) + target_wwns = list(set(target_wwns)) + else: + initiator_wwns = connector['wwpns'] + target_wwns = ports + for initiator in initiator_wwns: + init_targ_map[initiator] = target_wwns + + return target_wwns, init_targ_map + + def get_volume_stats(self, refresh=False): + stats = self.common.get_volume_stats(refresh) + stats['storage_protocol'] = 'FC' + stats['driver_version'] = self.VERSION + backend_name = self.configuration.safe_get('volume_backend_name') + stats['volume_backend_name'] = (backend_name or + self.__class__.__name__) + return stats + + def create_export(self, context, volume): + pass + + def ensure_export(self, context, volume): + pass + + def remove_export(self, context, volume): + pass + + def create_snapshot(self, snapshot): + self.common.create_snapshot(snapshot) + + def delete_snapshot(self, snapshot): + self.common.delete_snapshot(snapshot) + + def extend_volume(self, volume, new_size): + self.common.extend_volume(volume, new_size) + + def retype(self, context, volume, new_type, diff, host): + return self.common.retype(volume, new_type, diff, host) + + def manage_existing(self, volume, existing_ref): + self.common.manage_existing(volume, existing_ref) + + def manage_existing_get_size(self, volume, existing_ref): + return self.common.manage_existing_get_size(volume, existing_ref) + + def unmanage(self, volume): + pass diff --git a/cinder/volume/drivers/dothill/dothill_iscsi.py b/cinder/volume/drivers/dothill/dothill_iscsi.py new file mode 100644 index 000000000..ced984340 --- /dev/null +++ b/cinder/volume/drivers/dothill/dothill_iscsi.py @@ -0,0 +1,195 @@ +# Copyright 2014 Objectif Libre +# Copyright 2015 DotHill Systems +# +# 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 as logging + +from cinder import exception +from cinder.i18n import _ +import cinder.volume.driver +from cinder.volume.drivers.dothill import dothill_common as dothillcommon +from cinder.volume.drivers.san import san + + +DEFAULT_ISCSI_PORT = "3260" +LOG = logging.getLogger(__name__) + + +class DotHillISCSIDriver(cinder.volume.driver.ISCSIDriver): + """Openstack iSCSI cinder drivers for DotHill Arrays. + + Version history: + 0.1 - Base structure for DotHill iSCSI drivers based on HPMSA FC + drivers: + "https://github.com/openstack/cinder/tree/stable/juno/ + cinder/volume/drivers/san/hp" + 1.0 - Version developed for DotHill arrays with the following + modifications: + - added iSCSI support + - added CHAP support in iSCSI + - added support for v3 API(realstor feature) + - added support for retype volume + - added support for manage/unmanage volume + - added https support + + """ + + VERSION = "1.0" + + def __init__(self, *args, **kwargs): + super(DotHillISCSIDriver, self).__init__(*args, **kwargs) + self.common = None + self.configuration.append_config_values(dothillcommon.common_opt) + self.configuration.append_config_values(dothillcommon.iscsi_opt) + self.configuration.append_config_values(san.san_opts) + self.iscsi_ips = self.configuration.dothill_iscsi_ips + + def _init_common(self): + return dothillcommon.DotHillCommon(self.configuration) + + def _check_flags(self): + required_flags = ['san_ip', 'san_login', 'san_password'] + self.common.check_flags(self.configuration, required_flags) + + def do_setup(self, context): + self.common = self._init_common() + self._check_flags() + self.common.do_setup(context) + self.initialize_iscsi_ports() + + def initialize_iscsi_ports(self): + iscsi_ips = [] + if self.iscsi_ips: + for ip_addr in self.iscsi_ips: + ip = ip_addr.split(':') + if len(ip) == 1: + iscsi_ips.append([ip_addr, DEFAULT_ISCSI_PORT]) + elif len(ip) == 2: + iscsi_ips.append([ip[0], ip[1]]) + else: + msg = _("Invalid IP address format: '%s'") % ip_addr + LOG.error(msg) + raise exception.InvalidInput(reason=(msg)) + self.iscsi_ips = iscsi_ips + else: + msg = _('At least one valid iSCSI IP address must be set.') + LOG.error(msg) + raise exception.InvalidInput(reason=(msg)) + + def check_for_setup_error(self): + self._check_flags() + + def create_volume(self, volume): + return {'metadata': self.common.create_volume(volume)} + + def create_volume_from_snapshot(self, volume, src_vref): + self.common.create_volume_from_snapshot(volume, src_vref) + + def create_cloned_volume(self, volume, src_vref): + return {'metadata': self.common.create_cloned_volume(volume, src_vref)} + + def delete_volume(self, volume): + self.common.delete_volume(volume) + + def initialize_connection(self, volume, connector): + self.common.client_login() + try: + data = {} + data['target_lun'] = self.common.map_volume(volume, + connector, + 'initiator') + iqns = self.common.get_active_iscsi_target_iqns() + data['target_discovered'] = True + data['target_iqn'] = iqns[0] + iscsi_portals = self.common.get_active_iscsi_target_portals() + + for ip_port in self.iscsi_ips: + if (ip_port[0] in iscsi_portals): + data['target_portal'] = ":".join(ip_port) + break + + if 'target_portal' not in data: + raise exception.DotHillNotTargetPortal() + + if self.configuration.use_chap_auth: + chap_secret = self.common.get_chap_record( + connector['initiator'] + ) + if not chap_secret: + chap_secret = self.create_chap_record( + connector['initiator'] + ) + data['auth_password'] = chap_secret + data['auth_username'] = connector['initiator'] + data['auth_method'] = 'CHAP' + + info = {'driver_volume_type': 'iscsi', + 'data': data} + return info + finally: + self.common.client_logout() + + def terminate_connection(self, volume, connector, **kwargs): + self.common.unmap_volume(volume, connector, 'initiator') + + def get_volume_stats(self, refresh=False): + stats = self.common.get_volume_stats(refresh) + stats['storage_protocol'] = 'iSCSI' + stats['driver_version'] = self.VERSION + backend_name = self.configuration.safe_get('volume_backend_name') + stats['volume_backend_name'] = (backend_name or + self.__class__.__name__) + return stats + + def create_export(self, context, volume): + pass + + def ensure_export(self, context, volume): + pass + + def remove_export(self, context, volume): + pass + + def create_snapshot(self, snapshot): + self.common.create_snapshot(snapshot) + + def delete_snapshot(self, snapshot): + self.common.delete_snapshot(snapshot) + + def extend_volume(self, volume, new_size): + self.common.extend_volume(volume, new_size) + + def create_chap_record(self, initiator_name): + chap_secret = self.configuration.chap_password + # Chap secret length should be 12 to 16 characters + if 12 <= len(chap_secret) <= 16: + self.common.create_chap_record(initiator_name, chap_secret) + else: + msg = _('CHAP secret should be 12-16 bytes.') + LOG.error(msg) + raise exception.InvalidInput(reason=(msg)) + return chap_secret + + def retype(self, context, volume, new_type, diff, host): + return self.common.retype(volume, new_type, diff, host) + + def manage_existing(self, volume, existing_ref): + self.common.manage_existing(volume, existing_ref) + + def manage_existing_get_size(self, volume, existing_ref): + return self.common.manage_existing_get_size(volume, existing_ref) + + def unmanage(self, volume): + pass -- 2.45.2