From 1586b2f947c78bded4785aad1d39cbf415d96e4d Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Thu, 13 Feb 2014 21:34:59 +0100 Subject: [PATCH] Add HP MSA Fiber Channel driver This commit implements a driver for the HP MSA arrays. Only Fiber Channel is supported for now. Implements: blueprint add-msa-2040-driver DocImpact Change-Id: I75232cafadf7f6d15c5959d6c054f2e0f4d14e2c --- cinder/exception.py | 17 + cinder/tests/test_hp_msa.py | 549 ++++++++++++++++++ cinder/volume/drivers/san/hp/hp_msa_client.py | 240 ++++++++ cinder/volume/drivers/san/hp/hp_msa_common.py | 315 ++++++++++ cinder/volume/drivers/san/hp/hp_msa_fc.py | 158 +++++ etc/cinder/cinder.conf.sample | 8 + 6 files changed, 1287 insertions(+) create mode 100644 cinder/tests/test_hp_msa.py create mode 100644 cinder/volume/drivers/san/hp/hp_msa_client.py create mode 100644 cinder/volume/drivers/san/hp/hp_msa_common.py create mode 100644 cinder/volume/drivers/san/hp/hp_msa_fc.py diff --git a/cinder/exception.py b/cinder/exception.py index e2f1f7b97..e9d78c55a 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -672,3 +672,20 @@ class GlusterfsNoSuitableShareFound(VolumeDriverException): class RemoveExportException(VolumeDriverException): message = _("Failed to remove export for volume %(volume)s: %(reason)s") + + +# HP MSA +class HPMSAVolumeDriverException(VolumeDriverException): + message = _("HP MSA Volume Driver exception") + + +class HPMSAInvalidVDisk(HPMSAVolumeDriverException): + message = _("VDisk doesn't exist (%(vdisk)s)") + + +class HPMSAConnectionError(HPMSAVolumeDriverException): + message = _("Unable to connect to MSA array") + + +class HPMSANotEnoughSpace(HPMSAVolumeDriverException): + message = _("Not enough space on VDisk (%(vdisk)s)") diff --git a/cinder/tests/test_hp_msa.py b/cinder/tests/test_hp_msa.py new file mode 100644 index 000000000..662ebcbd4 --- /dev/null +++ b/cinder/tests/test_hp_msa.py @@ -0,0 +1,549 @@ +# (c) Copyright 2014 Objectif Libre +# +# 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 HP MSA driver.""" + +import lxml.etree as etree +import mock +import urllib2 + +from cinder import exception +from cinder import test +from cinder.volume.drivers.san.hp import hp_msa_client as msa +from cinder.volume.drivers.san.hp import hp_msa_common +from cinder.volume.drivers.san.hp import hp_msa_fc + + +session_key = 'JSESS0004eb8a82b08fd5' +resp_login = ''' + success + 0 + JSESS0004eb8a82b08fd5 + 1''' +resp_badlogin = ''' + ''' + +response_ok = ''' + some data + 0''' +response_not_ok = ''' + Error Message + 1 + ''' +response_stats = ''' + 1756381184 + 756381184 + ''' +response_no_lun = '''''' +response_lun = ''' + 1 + + 3''' +response_ports = ''' + FC + id1 + Up + + FC + id2 + Disconnected + + iSCSI + id3 + Up''' +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 = 'ecffc30f-98cb-4cf5-85ee-d7309cc17cd2' +test_volume = {'id': vol_id, + 'display_name': 'test volume', 'name': 'volume', 'size': 10} +test_snap = {'id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'volume_id': vol_id, + 'display_name': 'test volume', 'name': 'volume', 'size': 10} +encoded_volid = 'v7P_DD5jLTPWF7tcwnMF' +encoded_snapid = 's7P_DD5jLTPWF7tcwnMF' +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, + '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': 'iqn.1993-08.org.debian:01:222', + 'wwpns': [], + 'wwnns': [], + 'host': 'fakehost'} + + +class TestHPMSAClient(test.TestCase): + def setUp(self): + super(TestHPMSAClient, self).setUp() + self.login = 'manage' + self.passwd = '!manage' + self.ip = '10.0.0.1' + self.client = msa.HPMSAClient(self.ip, self.login, self.passwd) + + @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(self.client._session_key, session_key) + + m.read.side_effect = [resp_badlogin] + self.assertRaises(msa.HPMSAAuthenticationError, + self.client.login) + + def test_build_request_url(self): + url = self.client._build_request_url('/path', None) + self.assertEqual(url, 'http://10.0.0.1/api/path') + url = self.client._build_request_url('/path', None, arg1='val1') + self.assertEqual(url, 'http://10.0.0.1/api/path/arg1/val1') + url = self.client._build_request_url('/path', 'arg1') + self.assertEqual(url, 'http://10.0.0.1/api/path/arg1') + url = self.client._build_request_url('/path', 'arg1', arg2='val2') + self.assertEqual(url, 'http://10.0.0.1/api/path/arg2/val2/arg1') + url = self.client._build_request_url('/path', ['arg1', 'arg3'], + arg2='val2') + self.assertEqual(url, 'http://10.0.0.1/api/path/arg2/val2/arg1/arg3') + + @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', None) + self.assertTrue(type(ret) == etree._Element) + self.assertRaises(msa.HPMSAConnectionError, self.client._request, + '/path', None) + self.assertRaises(msa.HPMSAConnectionError, self.client._request, + '/path', None) + + 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(ret, None) + self.assertRaises(msa.HPMSARequestError, + self.client._assert_response_ok, not_ok_tree) + self.assertRaises(msa.HPMSARequestError, + self.client._assert_response_ok, invalid_tree) + + @mock.patch.object(msa.HPMSAClient, '_request') + def test_vdisk_exists(self, mock_request): + mock_request.side_effect = [msa.HPMSARequestError, + fake_xml] + + self.assertEqual(self.client.vdisk_exists('vdisk'), False) + self.assertEqual(self.client.vdisk_exists('vdisk'), True) + + @mock.patch.object(msa.HPMSAClient, '_request') + def test_vdisk_stats(self, mock_request): + mock_request.return_value = etree.XML(response_stats) + ret = self.client.vdisk_stats('OpenStack') + self.assertEqual(ret, {'free_capacity_gb': 387, + 'total_capacity_gb': 899}) + mock_request.assert_called_with('/show/vdisks', 'OpenStack') + + @mock.patch.object(msa.HPMSAClient, '_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(ret, 1) + ret = self.client._get_first_available_lun_for_host("fakehost") + self.assertEqual(ret, 2) + + @mock.patch.object(msa.HPMSAClient, '_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(ret, [{'port-type': 'FC', + 'target-id': 'id1', + 'status': 'Up'}, + {'port-type': 'iSCSI', + 'target-id': 'id3', + 'status': 'Up'}]) + + @mock.patch.object(msa.HPMSAClient, '_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(ret, ['id1']) + + +class FakeConfiguration(object): + msa_vdisk = 'OpenStack' + san_ip = '10.0.0.1' + san_login = 'manage' + san_password = '!manage' + + def safe_get(self, key): + return 'fakevalue' + + +class TestHPMSACommon(test.TestCase): + def setUp(self): + super(TestHPMSACommon, self).setUp() + self.config = FakeConfiguration() + self.common = hp_msa_common.HPMSACommon(self.config) + + @mock.patch.object(msa.HPMSAClient, 'vdisk_exists') + @mock.patch.object(msa.HPMSAClient, 'logout') + @mock.patch.object(msa.HPMSAClient, 'login') + def test_do_setup(self, mock_login, mock_logout, mock_vdisk_exists): + mock_login.side_effect = [msa.HPMSAConnectionError, + msa.HPMSAAuthenticationError, + None, None] + mock_vdisk_exists.side_effect = [False, True] + mock_logout.return_value = None + + self.assertRaises(exception.HPMSAConnectionError, + self.common.do_setup, None) + self.assertRaises(exception.HPMSAConnectionError, + self.common.do_setup, None) + self.assertRaises(exception.HPMSAInvalidVDisk, self.common.do_setup, + None) + mock_vdisk_exists.assert_called_with(self.config.msa_vdisk) + self.assertEqual(self.common.do_setup(None), None) + mock_vdisk_exists.assert_called_with(self.config.msa_vdisk) + mock_logout.assert_called_with() + + def test_vol_name(self): + self.assertEqual(self.common._get_vol_name(vol_id), encoded_volid) + self.assertEqual(self.common._get_snap_name(vol_id), + encoded_snapid) + + def test_check_flags(self): + class FakeOptions(): + 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(ret, None) + + options = FakeOptions({'opt1': 'val1', 'opt3': 'val3'}) + required_flags = ['opt1', 'opt2'] + self.assertEqual(ret, None) + + 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.assertEqual(None, + self.common._assert_connector_ok(connector)) + + @mock.patch.object(msa.HPMSAClient, 'vdisk_stats') + def test_update_volume_stats(self, mock_stats): + mock_stats.side_effect = [msa.HPMSARequestError, + stats_large_space] + + self.assertRaises(exception.Invalid, self.common._update_volume_stats) + mock_stats.assert_called_with(self.config.msa_vdisk) + ret = self.common._update_volume_stats() + self.assertEqual(ret, None) + self.assertEqual(self.common.stats, + {'storage_protocol': None, + 'vendor_name': 'Hewlett-Packard', + 'driver_version': self.common.VERSION, + 'volume_backend_name': None, + 'free_capacity_gb': 90, + 'reserved_percentage': 0, + 'total_capacity_gb': 100, + 'QoS_support': False}) + + @mock.patch.object(msa.HPMSAClient, 'create_volume') + def test_create_volume(self, mock_create): + mock_create.side_effect = [msa.HPMSARequestError, None] + + self.assertRaises(exception.Invalid, self.common.create_volume, + test_volume) + ret = self.common.create_volume(test_volume) + self.assertEqual(ret, None) + mock_create.assert_called_with(self.common.config.msa_vdisk, + encoded_volid, + "%sGB" % test_volume['size']) + + @mock.patch.object(msa.HPMSAClient, 'delete_volume') + def test_delete_volume(self, mock_delete): + not_found_e = msa.HPMSARequestError( + 'The volume was not found on this system.') + mock_delete.side_effect = [not_found_e, msa.HPMSARequestError, + None] + + self.assertEqual(self.common.delete_volume(test_volume), None) + self.assertRaises(exception.Invalid, self.common.delete_volume, + test_volume) + self.assertEqual(self.common.delete_volume(test_volume), None) + mock_delete.assert_called_with(encoded_volid) + + @mock.patch.object(msa.HPMSAClient, 'copy_volume') + @mock.patch.object(msa.HPMSAClient, 'vdisk_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.HPMSANotEnoughSpace, + self.common.create_cloned_volume, + dest_volume, detached_volume) + self.assertFalse(mock_copy.called) + + mock_copy.side_effect = [msa.HPMSARequestError, 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(ret, None) + + mock_copy.assert_called_with(encoded_volid, + 'vqqqqqqqqqqqqqqqqqqq', + self.common.config.msa_vdisk) + + @mock.patch.object(msa.HPMSAClient, 'copy_volume') + @mock.patch.object(msa.HPMSAClient, 'vdisk_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.HPMSANotEnoughSpace, + self.common.create_volume_from_snapshot, + dest_volume, test_snap) + + mock_copy.side_effect = [msa.HPMSARequestError, 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(ret, None) + mock_copy.assert_called_with('sqqqqqqqqqqqqqqqqqqq', + 'vqqqqqqqqqqqqqqqqqqq', + self.common.config.msa_vdisk) + + @mock.patch.object(msa.HPMSAClient, 'extend_volume') + def test_extend_volume(self, mock_extend): + mock_extend.side_effect = [msa.HPMSARequestError, None] + + self.assertRaises(exception.Invalid, self.common.extend_volume, + test_volume, 20) + ret = self.common.extend_volume(test_volume, 20) + self.assertEqual(ret, None) + mock_extend.assert_called_with(encoded_volid, '10GB') + + @mock.patch.object(msa.HPMSAClient, 'create_snapshot') + def test_create_snapshot(self, mock_create): + mock_create.side_effect = [msa.HPMSARequestError, None] + + self.assertRaises(exception.Invalid, self.common.create_snapshot, + test_snap) + ret = self.common.create_snapshot(test_snap) + self.assertEqual(ret, None) + mock_create.assert_called_with(encoded_volid, 'sqqqqqqqqqqqqqqqqqqq') + + @mock.patch.object(msa.HPMSAClient, 'delete_snapshot') + def test_delete_snapshot(self, mock_delete): + not_found_e = msa.HPMSARequestError( + 'The volume was not found on this system.') + mock_delete.side_effect = [not_found_e, msa.HPMSARequestError, + None] + + self.assertEqual(self.common.delete_snapshot(test_snap), None) + self.assertRaises(exception.Invalid, self.common.delete_snapshot, + test_snap) + self.assertEqual(self.common.delete_snapshot(test_snap), None) + mock_delete.assert_called_with('sqqqqqqqqqqqqqqqqqqq') + + @mock.patch.object(msa.HPMSAClient, 'map_volume') + def test_map_volume(self, mock_map): + mock_map.side_effect = [msa.HPMSARequestError, 10] + + self.assertRaises(exception.Invalid, self.common.map_volume, + test_volume, connector) + lun = self.common.map_volume(test_volume, connector) + self.assertEqual(lun, 10) + mock_map.assert_called_with(encoded_volid, connector['wwpns']) + + @mock.patch.object(msa.HPMSAClient, 'unmap_volume') + def test_unmap_volume(self, mock_unmap): + mock_unmap.side_effect = [msa.HPMSARequestError, None] + + self.assertRaises(exception.Invalid, self.common.unmap_volume, + test_volume, connector) + ret = self.common.unmap_volume(test_volume, connector) + self.assertEqual(ret, None) + mock_unmap.assert_called_with(encoded_volid, connector['wwpns']) + + +class TestHPMSAFC(test.TestCase): + @mock.patch.object(hp_msa_common.HPMSACommon, 'do_setup') + def setUp(self, mock_setup): + super(TestHPMSAFC, self).setUp() + + mock_setup.return_value = True + + def fake_init(self, *args, **kwargs): + super(hp_msa_fc.HPMSAFCDriver, self).__init__() + self.common = None + self.configuration = FakeConfiguration() + + hp_msa_fc.HPMSAFCDriver.__init__ = fake_init + self.driver = hp_msa_fc.HPMSAFCDriver() + self.driver.do_setup(None) + self.driver.common.client_login = mock.MagicMock(return_value=None) + self.driver.common.client_logout = mock.MagicMock(return_value=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(hp_msa_common.HPMSACommon, 'create_volume') + def test_create_volume(self, mock_create): + self._test_with_mock(mock_create, 'create_volume', [None], + {'metadata': None}) + + @mock.patch.object(hp_msa_common.HPMSACommon, + '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(hp_msa_common.HPMSACommon, + '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(hp_msa_common.HPMSACommon, 'delete_volume') + def test_delete_volume(self, mock_delete): + self._test_with_mock(mock_delete, 'delete_volume', [None]) + + @mock.patch.object(hp_msa_common.HPMSACommon, 'create_snapshot') + def test_create_snapshot(self, mock_create): + self._test_with_mock(mock_create, 'create_snapshot', [None]) + + @mock.patch.object(hp_msa_common.HPMSACommon, 'delete_snapshot') + def test_delete_snapshot(self, mock_delete): + self._test_with_mock(mock_delete, 'delete_snapshot', [None]) + + @mock.patch.object(hp_msa_common.HPMSACommon, 'extend_volume') + def test_extend_volume(self, mock_extend): + self._test_with_mock(mock_extend, 'extend_volume', [None, 10]) + + @mock.patch.object(hp_msa_common.HPMSACommon, 'client_logout') + @mock.patch.object(hp_msa_common.HPMSACommon, + 'get_active_fc_target_ports') + @mock.patch.object(hp_msa_common.HPMSACommon, 'map_volume') + @mock.patch.object(hp_msa_common.HPMSACommon, '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) + + ret = self.driver.initialize_connection(test_volume, connector) + self.assertEqual(ret, {'driver_volume_type': 'fibre_channel', + 'data': {'target_wwn': ['id1'], + 'target_lun': 1, + 'target_discovered': True}}) + mock_ports.assert_called_once() + + @mock.patch.object(hp_msa_common.HPMSACommon, 'client_logout') + @mock.patch.object(hp_msa_common.HPMSACommon, 'unmap_volume') + @mock.patch.object(hp_msa_common.HPMSACommon, 'client_login') + def test_terminate_connection(self, mock_login, mock_unmap, mock_logout): + mock_login.return_value = None + mock_logout.return_value = None + 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) + + ret = self.driver.terminate_connection(test_volume, connector) + self.assertEqual(ret, None) + + @mock.patch.object(hp_msa_common.HPMSACommon, 'client_logout') + @mock.patch.object(hp_msa_common.HPMSACommon, 'get_volume_stats') + @mock.patch.object(hp_msa_common.HPMSACommon, 'client_login') + def test_get_volume_stats(self, mock_login, mock_stats, mock_logout): + stats = {'storage_protocol': None, + 'driver_version': self.driver.VERSION, + 'volume_backend_name': None, + 'free_capacity_gb': 90, + 'reserved_percentage': 0, + 'total_capacity_gb': 100, + 'QoS_support': False} + 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(ret, {'storage_protocol': 'FC', + 'driver_version': self.driver.VERSION, + 'volume_backend_name': 'fakevalue', + 'free_capacity_gb': 90, + 'reserved_percentage': 0, + 'total_capacity_gb': 100, + 'QoS_support': False}) + + ret = self.driver.get_volume_stats(True) + self.assertEqual(ret, {'storage_protocol': 'FC', + 'driver_version': self.driver.VERSION, + 'volume_backend_name': 'fakevalue', + 'free_capacity_gb': 90, + 'reserved_percentage': 0, + 'total_capacity_gb': 100, + 'QoS_support': False}) + mock_stats.assert_called_with(True) diff --git a/cinder/volume/drivers/san/hp/hp_msa_client.py b/cinder/volume/drivers/san/hp/hp_msa_client.py new file mode 100644 index 000000000..211046fc4 --- /dev/null +++ b/cinder/volume/drivers/san/hp/hp_msa_client.py @@ -0,0 +1,240 @@ +# Copyright 2014 Objectif Libre +# +# 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 urllib2 + +from lxml import etree + + +class HPMSAConnectionError(Exception): + pass + + +class HPMSAAuthenticationError(Exception): + pass + + +class HPMSARequestError(Exception): + pass + + +class HPMSAClient(object): + def __init__(self, host, login, password, protocol='http'): + 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 + obj = etree.XML(xml).find("OBJECT") + for prop in obj.iter("PROPERTY"): + if prop.get("name") == "response": + self._session_key = prop.text + break + + 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 HPMSAConnectionError() + + self._get_auth_token(xml) + + if self._session_key is None: + raise HPMSAAuthenticationError() + + def _assert_response_ok(self, tree): + """Parses the XML returned by the device to check the return code. + + Raises a HPMSARequestError error if the return code is not 0. + """ + + for obj in tree.iter(): + if obj.get("basetype") != "status": + continue + + ret_code = ret_str = None + for prop in obj.iter("PROPERTY"): + if prop.get("name") == "return-code": + ret_code = prop.text + elif prop.get("name") == "response": + ret_str = prop.text + + if ret_code != "0": + raise HPMSARequestError(ret_str) + else: + return + + raise HPMSARequestError("No status found") + + def _build_request_url(self, path, args=None, **kargs): + url = self._base_url + path + if kargs: + url += '/' + '/'.join(["%s/%s" % (k.replace('_', '-'), v) + for (k, v) in kargs.items()]) + if args: + if not isinstance(args, list): + args = [args] + url += '/' + '/'.join(args) + + return url + + def _request(self, path, args=None, **kargs): + """Performs an HTTP request on the device. + + Raises a HPMSARequestError 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() + except urllib2.URLError: + raise HPMSAConnectionError() + + try: + tree = etree.XML(xml) + except etree.LxmlError: + raise HPMSAConnectionError() + + self._assert_response_ok(tree) + return tree + + def logout(self): + url = self._base_url + '/exit' + try: + urllib2.urlopen(url) + return True + except HPMSARequestError: + return False + + def create_volume(self, vdisk, name, size): + # NOTE: size is in this format: [0-9]+GB + self._request("/create/volume", name, vdisk=vdisk, size=size) + 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 vdisk_exists(self, vdisk): + try: + self._request("/show/vdisks", vdisk) + return True + except HPMSARequestError: + return False + + def vdisk_stats(self, vdisk): + stats = {'free_capacity_gb': 0, + 'total_capacity_gb': 0} + tree = self._request("/show/vdisks", vdisk) + + for obj in tree.iter(): + if obj.get("basetype") != "virtual-disks": + continue + + for prop in obj.iter("PROPERTY"): + # the sizes are given in number of blocks of 512 octets + if prop.get("name") == "size-numeric": + stats['total_capacity_gb'] = \ + int(prop.text) * 512 / (10 ** 9) + elif prop.get("name") == "freespace-numeric": + stats['free_capacity_gb'] = \ + int(prop.text) * 512 / (10 ** 9) + + return stats + + def _get_first_available_lun_for_host(self, host): + luns = [] + tree = self._request("/show/host-maps", host) + + for obj in tree.iter(): + if obj.get("basetype") != "host-view-mappings": + continue + + for prop in obj.iter("PROPERTY"): + if prop.get("name") == "lun": + luns.append(int(prop.text)) + + lun = 1 + while True: + if lun not in luns: + return lun + lun += 1 + + def map_volume(self, volume_name, wwpns): + # NOTE(gpocentek): we assume that luns will be the same for all hosts + lun = self._get_first_available_lun_for_host(wwpns[0]) + hosts = ",".join(wwpns) + self._request("/map/volume", volume_name, + lun=str(lun), host=hosts, access="rw") + return lun + + def unmap_volume(self, volume_name, wwpns): + hosts = ",".join(wwpns) + self._request("/unmap/volume", volume_name, host=hosts) + + def get_active_target_ports(self): + ports = [] + tree = self._request("/show/ports") + + for obj in tree.iter(): + if obj.get("basetype") != "port": + continue + + port = {} + for prop in obj.iter("PROPERTY"): + prop_name = prop.get("name") + if prop_name in ["port-type", "target-id", "status"]: + port[prop_name] = prop.text + if port['status'] != 'Up': + continue + ports.append(port) + + return ports + + def get_active_fc_target_ports(self): + ports = [] + for port in self.get_active_target_ports(): + if port['port-type'] == "FC": + ports.append(port['target-id']) + + return ports + + def copy_volume(self, source_name, target_name, vdisk): + self._request("/volumecopy", target_name, + dest_vdisk=vdisk, + source_volume=source_name, + prompt='yes') diff --git a/cinder/volume/drivers/san/hp/hp_msa_common.py b/cinder/volume/drivers/san/hp/hp_msa_common.py new file mode 100644 index 000000000..222331184 --- /dev/null +++ b/cinder/volume/drivers/san/hp/hp_msa_common.py @@ -0,0 +1,315 @@ +# Copyright 2014 Objectif Libre +# +# 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 HP MSA Storage array +""" + +import base64 +import uuid + +from oslo.config import cfg + +from cinder import exception +from cinder.openstack.common import log as logging +from cinder.volume.drivers.san.hp import hp_msa_client as msa + +LOG = logging.getLogger(__name__) + +hpmsa_opt = [ + cfg.StrOpt('msa_vdisk', + default='OpenStack', + help="The VDisk to use for volume creation."), +] + +CONF = cfg.CONF +CONF.register_opts(hpmsa_opt) + + +class HPMSACommon(object): + VERSION = "0.1" + + stats = {} + + def __init__(self, config): + self.config = config + self.client = msa.HPMSAClient(self.config.san_ip, + self.config.san_login, + self.config.san_password) + + self.vdisk = self.config.msa_vdisk + + def get_version(self): + return self.VERSION + + def do_setup(self, context): + self.client_login() + self._validate_vdisks() + self.client_logout() + + def client_login(self): + LOG.debug(_("Connecting to MSA")) + try: + self.client.login() + except msa.HPMSAConnectionError as ex: + msg = (_("Failed to connect to MSA Array (%(host)s): %(err)s") % + {'host': self.config.san_ip, 'err': ex}) + LOG.error(msg) + raise exception.HPMSAConnectionError(reason=msg) + except msa.HPMSAAuthenticationError as e: + msg = _("Failed to log on MSA Array (invalid login?)") + LOG.error(msg) + raise exception.HPMSAConnectionError(reason=msg) + + def _validate_vdisks(self): + if not self.client.vdisk_exists(self.vdisk): + self.client_logout() + raise exception.HPMSAInvalidVDisk(vdisk=self.vdisk) + + def client_logout(self): + self.client.logout() + LOG.debug(_("Disconnected from MSA Array")) + + 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 MSA volume name. + + Converts the openstack volume id from + ecffc30f-98cb-4cf5-85ee-d7309cc17cd2 + to + 7P_DD5jLTPWF7tcwnMF80g + + We convert the 128 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 MSA + vol_encoded = vol_encoded.replace('+', '.') + # since we use http URLs to send paramters, '/' is not an acceptable + # parameter + vol_encoded = vol_encoded.replace('/', '_') + + # NOTE(gpocentek): 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): + volume_id = self._get_vol_name(volume['id']) + LOG.debug(_("Create Volume (%(display_name)s: %(name)s %(id)s)") % + {'display_name': volume['display_name'], + 'name': volume['name'], 'id': volume_id}) + + # use base64 to encode the volume name (UUID is too long for MSA) + volume_name = self._get_vol_name(volume['id']) + volume_size = "%dGB" % volume['size'] + try: + metadata = self.client.create_volume(self.config.msa_vdisk, + volume_name, + volume_size) + except msa.HPMSARequestError as ex: + LOG.error(ex) + raise exception.Invalid(ex) + + return metadata + + def _assert_enough_space_for_copy(self, volume_size): + """The MSA 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['free_capacity_gb']: + raise exception.HPMSANotEnoughSpace(vdisk=self.vdisk) + + def _assert_source_detached(self, volume): + """The MSA 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": + msg = _("Volume must be detached to perform a clone operation.") + LOG.error(msg) + raise exception.VolumeAttached(volume_id=volume['id']) + + def create_cloned_volume(self, volume, src_vref): + 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 (%(dest_id)s)") % + {'source_id': volume['source_volid'], + 'dest_id': volume['id']}) + + orig_name = self._get_vol_name(volume['source_volid']) + dest_name = self._get_vol_name(volume['id']) + try: + self.client.copy_volume(orig_name, dest_name, + self.config.msa_vdisk) + except msa.HPMSARequestError as ex: + LOG.error(ex) + raise exception.Invalid(ex) + + return None + + def create_volume_from_snapshot(self, volume, snapshot): + self.get_volume_stats(True) + self._assert_enough_space_for_copy(volume['size']) + + LOG.debug(_("Creating Volume from snapshot %(source_id)s " + "(%(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']) + try: + self.client.copy_volume(orig_name, dest_name, + self.config.msa_vdisk) + except msa.HPMSARequestError as ex: + LOG.error(ex) + raise exception.Invalid(ex) + + return None + + def delete_volume(self, volume): + LOG.debug(_("Deleting Volume (%s)") % volume['id']) + volume_name = self._get_vol_name(volume['id']) + try: + self.client.delete_volume(volume_name) + except msa.HPMSARequestError as ex: + LOG.error(ex) + # if the volume wasn't found, ignore the error + if 'The volume was not found on this system.' in ex: + return + raise exception.Invalid(ex) + + def get_volume_stats(self, refresh): + if refresh: + self._update_volume_stats() + + 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, + 'free_capacity_gb': 'unknown', + 'reserved_percentage': 0, + 'storage_protocol': None, + 'total_capacity_gb': 'unknown', + 'QoS_support': False, + 'vendor_name': 'Hewlett-Packard', + 'volume_backend_name': None} + + try: + vdisk_stats = self.client.vdisk_stats(self.config.msa_vdisk) + stats.update(vdisk_stats) + except msa.HPMSARequestError: + err = (_("Unable to get stats for VDisk (%s)") + % self.config.msa_vdisk) + LOG.error(err) + raise exception.Invalid(reason=err) + + self.stats = stats + + def _assert_connector_ok(self, connector): + if not connector['wwpns']: + msg = _("Connector doesn't provide wwpns") + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + + def map_volume(self, volume, connector): + self._assert_connector_ok(connector) + volume_name = self._get_vol_name(volume['id']) + try: + data = self.client.map_volume(volume_name, connector['wwpns']) + return data + except msa.HPMSARequestError as ex: + LOG.error(ex) + raise exception.Invalid(ex) + + def unmap_volume(self, volume, connector): + self._assert_connector_ok(connector) + volume_name = self._get_vol_name(volume['id']) + try: + self.client.unmap_volume(volume_name, connector['wwpns']) + except msa.HPMSARequestError as ex: + LOG.error(ex) + raise exception.Invalid(ex) + + def get_active_fc_target_ports(self): + return self.client.get_active_fc_target_ports() + + def create_snapshot(self, snapshot): + LOG.debug(_("Creating Snapshot from %(volume_id)s (%(snap_id)s)") % + {'volume_id': snapshot['volume_id'], + 'snap_id': snapshot['id']}) + snap_name = self._get_snap_name(snapshot['id']) + vol_name = self._get_vol_name(snapshot['volume_id']) + try: + self.client.create_snapshot(vol_name, snap_name) + except msa.HPMSARequestError as ex: + LOG.error(ex) + raise exception.Invalid(ex) + + def delete_snapshot(self, snapshot): + snap_name = self._get_snap_name(snapshot['id']) + LOG.debug(_("Deleting Snapshot (%s)") % snapshot['id']) + + try: + self.client.delete_snapshot(snap_name) + except msa.HPMSARequestError as ex: + LOG.error(ex) + # if the volume wasn't found, ignore the error + if 'The volume was not found on this system.' in ex: + return + raise exception.Invalid(ex) + + def extend_volume(self, volume, new_size): + 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}) + try: + self.client.extend_volume(volume_name, "%dGB" % growth_size) + except msa.HPMSARequestError as ex: + LOG.error(ex) + raise exception.Invalid(ex) diff --git a/cinder/volume/drivers/san/hp/hp_msa_fc.py b/cinder/volume/drivers/san/hp/hp_msa_fc.py new file mode 100644 index 000000000..c86650c24 --- /dev/null +++ b/cinder/volume/drivers/san/hp/hp_msa_fc.py @@ -0,0 +1,158 @@ +# Copyright 2014 Objectif Libre +# +# 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.openstack.common import log as logging +from cinder import utils +import cinder.volume.driver +from cinder.volume.drivers.san.hp import hp_msa_common as hpcommon +from cinder.volume.drivers.san import san + +LOG = logging.getLogger(__name__) + + +class HPMSAFCDriver(cinder.volume.driver.FibreChannelDriver): + VERSION = "0.1" + + def __init__(self, *args, **kwargs): + super(HPMSAFCDriver, self).__init__(*args, **kwargs) + self.common = None + self.configuration.append_config_values(hpcommon.hpmsa_opt) + self.configuration.append_config_values(san.san_opts) + + def _init_common(self): + return hpcommon.HPMSACommon(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() + + @utils.synchronized('msa', external=True) + def create_volume(self, volume): + self.common.client_login() + try: + metadata = self.common.create_volume(volume) + return {'metadata': metadata} + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def create_volume_from_snapshot(self, volume, src_vref): + self.common.client_login() + try: + self.common.create_volume_from_snapshot(volume, src_vref) + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def create_cloned_volume(self, volume, src_vref): + self.common.client_login() + try: + new_vol = self.common.create_cloned_volume(volume, src_vref) + return {'metadata': new_vol} + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def delete_volume(self, volume): + self.common.client_login() + try: + self.common.delete_volume(volume) + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def initialize_connection(self, volume, connector): + self.common.client_login() + try: + data = {} + data['target_lun'] = self.common.map_volume(volume, connector) + + ports = self.common.get_active_fc_target_ports() + data['target_discovered'] = True + data['target_wwn'] = ports + + info = {'driver_volume_type': 'fibre_channel', + 'data': data} + return info + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def terminate_connection(self, volume, connector, **kwargs): + self.common.client_login() + try: + self.common.unmap_volume(volume, connector) + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def get_volume_stats(self, refresh=False): + if refresh: + self.common.client_login() + try: + 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 + finally: + if refresh: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def create_export(self, context, volume): + pass + + @utils.synchronized('msa', external=True) + def ensure_export(self, context, volume): + pass + + @utils.synchronized('msa', external=True) + def remove_export(self, context, volume): + pass + + @utils.synchronized('msa', external=True) + def create_snapshot(self, snapshot): + self.common.client_login() + try: + self.common.create_snapshot(snapshot) + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def delete_snapshot(self, snapshot): + self.common.client_login() + try: + self.common.delete_snapshot(snapshot) + finally: + self.common.client_logout() + + @utils.synchronized('msa', external=True) + def extend_volume(self, volume, new_size): + self.common.client_login() + try: + self.common.extend_volume(volume, new_size) + finally: + self.common.client_logout() diff --git a/etc/cinder/cinder.conf.sample b/etc/cinder/cinder.conf.sample index a0890480c..b7d490124 100644 --- a/etc/cinder/cinder.conf.sample +++ b/etc/cinder/cinder.conf.sample @@ -1539,6 +1539,14 @@ #hplefthand_debug=false +# +# Options defined in cinder.volume.drivers.san.hp.hp_msa_common +# + +# The VDisk to use for volume creation. (string value) +#msa_vdisk=OpenStack + + # # Options defined in cinder.volume.drivers.san.san # -- 2.45.2