From: Victor A. Ying Date: Mon, 28 Jul 2014 20:30:57 +0000 (-0700) Subject: Introduce iSCSI driver for Pure Storage FlashArray X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=5efb55cb93c385f07d5655288ad0c297977362ae;p=openstack-build%2Fcinder-build.git Introduce iSCSI driver for Pure Storage FlashArray This patch introduces an iSCSI driver for Pure Storage FlashArray to Cinder. It impliments all required features. Certification test results: https://bugs.launchpad.net/cinder/+bug/1347109 Change-Id: I25f465b4abfc19d9b2717ec095c65201e76beab9 Impliements: blueprint pure-iscsi-volume-driver --- diff --git a/cinder/exception.py b/cinder/exception.py index b72d9d08b..11f20522b 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -613,6 +613,15 @@ class CoraidESMNotAvailable(CoraidException): message = _('Coraid ESM not available with reason: %(reason)s') +# Pure Storage +class PureDriverException(VolumeDriverException): + message = _("Pure Storage Cinder driver failure: %(reason)s") + + +class PureAPIException(VolumeBackendAPIException): + message = _("Bad response from Pure Storage REST API: %(reason)s") + + # Zadara class ZadaraException(VolumeDriverException): message = _('Zadara Cinder Driver exception.') diff --git a/cinder/tests/test_pure.py b/cinder/tests/test_pure.py new file mode 100644 index 000000000..cf9183720 --- /dev/null +++ b/cinder/tests/test_pure.py @@ -0,0 +1,632 @@ +# Copyright (c) 2014 Pure Storage, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import mock +import urllib2 + +from cinder import exception +from cinder.openstack.common import processutils +from cinder.openstack.common import units +from cinder import test +from cinder.volume.drivers import pure + +DRIVER_PATH = "cinder.volume.drivers.pure" +DRIVER_OBJ = DRIVER_PATH + ".PureISCSIDriver" +ARRAY_OBJ = DRIVER_PATH + ".FlashArray" + +TARGET = "pure-target" +API_TOKEN = "12345678-abcd-1234-abcd-1234567890ab" +VOLUME_BACKEND_NAME = "Pure_iSCSI" +PORT_NAMES = ["ct0.eth2", "ct0.eth3", "ct1.eth2", "ct1.eth3"] +ISCSI_IPS = ["10.0.0." + str(i + 1) for i in range(len(PORT_NAMES))] +HOST_NAME = "pure-host" +REST_VERSION = "1.2" +VOLUME_ID = "abcdabcd-1234-abcd-1234-abcdeffedcba" +VOLUME = {"name": "volume-" + VOLUME_ID, + "id": VOLUME_ID, + "display_name": "fake_volume", + "size": 2, + "host": "irrelevant", + "volume_type": None, + "volume_type_id": None, + } +SRC_VOL_ID = "dc7a294d-5964-4379-a15f-ce5554734efc" +SRC_VOL = {"name": "volume-" + SRC_VOL_ID, + "id": SRC_VOL_ID, + "display_name": 'fake_src', + "size": 2, + "host": "irrelevant", + "volume_type": None, + "volume_type_id": None, + } +SNAPSHOT_ID = "04fe2f9a-d0c4-4564-a30d-693cc3657b47" +SNAPSHOT = {"name": "snapshot-" + SNAPSHOT_ID, + "id": SNAPSHOT_ID, + "volume_id": SRC_VOL_ID, + "volume_name": "volume-" + SRC_VOL_ID, + "volume_size": 2, + "display_name": "fake_snapshot", + } +INITIATOR_IQN = "iqn.1993-08.org.debian:01:222" +CONNECTOR = {"initiator": INITIATOR_IQN} +TARGET_IQN = "iqn.2010-06.com.purestorage:flasharray.12345abc" +TARGET_PORT = "3260" +ISCSI_PORTS = [{"name": name, + "iqn": TARGET_IQN, + "portal": ip + ":" + TARGET_PORT, + "wwn": None, + } for name, ip in zip(PORT_NAMES, ISCSI_IPS)] +NON_ISCSI_PORT = {"name": "ct0.fc1", + "iqn": None, + "portal": None, + "wwn": "5001500150015081", + } +PORTS_WITH = ISCSI_PORTS + [NON_ISCSI_PORT] +PORTS_WITHOUT = [NON_ISCSI_PORT] +VOLUME_CONNECTIONS = [{"host": "h1", "name": VOLUME["name"] + "-cinder"}, + {"host": "h2", "name": VOLUME["name"] + "-cinder"}, + ] +TOTAL_SPACE = 50.0 +FREE_SPACE = 32.1 +SPACE_INFO = {"capacity": TOTAL_SPACE * units.Gi, + "total": (TOTAL_SPACE - FREE_SPACE) * units.Gi, + } + + +class PureISCSIDriverTestCase(test.TestCase): + + def setUp(self): + super(PureISCSIDriverTestCase, self).setUp() + self.config = mock.Mock() + self.config.san_ip = TARGET + self.config.pure_api_token = API_TOKEN + self.config.volume_backend_name = VOLUME_BACKEND_NAME + self.driver = pure.PureISCSIDriver(configuration=self.config) + self.array = mock.create_autospec(pure.FlashArray) + self.driver._array = self.array + + @mock.patch(ARRAY_OBJ, autospec=True) + @mock.patch(DRIVER_OBJ + "._choose_target_iscsi_port") + def test_do_setup(self, mock_choose_target_iscsi_port, mock_array): + mock_choose_target_iscsi_port.return_value = ISCSI_PORTS[0] + mock_array.return_value = self.array + self.driver.do_setup(None) + mock_array.assert_called_with(TARGET, API_TOKEN) + self.assertEqual(self.array, self.driver._array) + mock_choose_target_iscsi_port.assert_called_with() + self.assertEqual(ISCSI_PORTS[0], self.driver._iscsi_port) + self.assert_error_propagates( + [mock_array, mock_choose_target_iscsi_port], + self.driver.do_setup, None) + + def assert_error_propagates(self, mocks, func, *args, **kwargs): + """Assert that errors from mocks propogate to func. + + Fail if exceptions raised by mocks are not seen when calling + func(*args, **kwargs). Ensure that we are really seeing exceptions + from the mocks by failing if just running func(*args, **kargs) raises + an exception itself. + """ + func(*args, **kwargs) + for mock_func in mocks: + mock_func.side_effect = exception.PureDriverException( + reason="reason") + self.assertRaises(exception.PureDriverException, + func, *args, **kwargs) + mock_func.side_effect = None + + def test_create_volume(self): + self.driver.create_volume(VOLUME) + self.array.create_volume.assert_called_with( + VOLUME["name"] + "-cinder", 2 * units.Gi) + self.assert_error_propagates([self.array.create_volume], + self.driver.create_volume, VOLUME) + + def test_create_volume_from_snapshot(self): + vol_name = VOLUME["name"] + "-cinder" + snap_name = SNAPSHOT["volume_name"] + "-cinder." + SNAPSHOT["name"] + # Branch where extend unneeded + self.driver.create_volume_from_snapshot(VOLUME, SNAPSHOT) + self.array.copy_volume.assert_called_with(snap_name, vol_name) + self.assertFalse(self.array.extend_volume.called) + self.assert_error_propagates( + [self.array.copy_volume], + self.driver.create_volume_from_snapshot, VOLUME, SNAPSHOT) + self.assertFalse(self.array.extend_volume.called) + # Branch where extend needed + SNAPSHOT["volume_size"] = 1 # resize so smaller than VOLUME + self.driver.create_volume_from_snapshot(VOLUME, SNAPSHOT) + expected = [mock.call.copy_volume(snap_name, vol_name), + mock.call.extend_volume(vol_name, 2 * units.Gi)] + self.array.assert_has_calls(expected) + self.assert_error_propagates( + [self.array.copy_volume, self.array.extend_volume], + self.driver.create_volume_from_snapshot, VOLUME, SNAPSHOT) + SNAPSHOT["volume_size"] = 2 # reset size + + def test_create_cloned_volume(self): + vol_name = VOLUME["name"] + "-cinder" + src_name = SRC_VOL["name"] + "-cinder" + # Branch where extend unneeded + self.driver.create_cloned_volume(VOLUME, SRC_VOL) + self.array.copy_volume.assert_called_with(src_name, vol_name) + self.assertFalse(self.array.extend_volume.called) + self.assert_error_propagates( + [self.array.copy_volume], + self.driver.create_cloned_volume, VOLUME, SRC_VOL) + self.assertFalse(self.array.extend_volume.called) + # Branch where extend needed + SRC_VOL["size"] = 1 # resize so smaller than VOLUME + self.driver.create_cloned_volume(VOLUME, SRC_VOL) + expected = [mock.call.copy_volume(src_name, vol_name), + mock.call.extend_volume(vol_name, 2 * units.Gi)] + self.array.assert_has_calls(expected) + self.assert_error_propagates( + [self.array.copy_volume, self.array.extend_volume], + self.driver.create_cloned_volume, VOLUME, SRC_VOL) + SRC_VOL["size"] = 2 # reset size + + def test_delete_volume(self): + vol_name = VOLUME["name"] + "-cinder" + self.driver.delete_volume(VOLUME) + expected = [mock.call.destroy_volume(vol_name)] + self.array.assert_has_calls(expected) + self.assert_error_propagates([self.array.destroy_volume], + self.driver.delete_volume, VOLUME) + + def test_create_snapshot(self): + vol_name = SRC_VOL["name"] + "-cinder" + self.driver.create_snapshot(SNAPSHOT) + self.array.create_snapshot.assert_called_with(vol_name, + SNAPSHOT["name"]) + self.assert_error_propagates([self.array.create_snapshot], + self.driver.create_snapshot, SNAPSHOT) + + def test_delete_snapshot(self): + snap_name = SNAPSHOT["volume_name"] + "-cinder." + SNAPSHOT["name"] + self.driver.delete_snapshot(SNAPSHOT) + expected = [mock.call.destroy_volume(snap_name)] + self.array.assert_has_calls(expected) + self.assert_error_propagates([self.array.destroy_volume], + self.driver.delete_snapshot, SNAPSHOT) + + @mock.patch(DRIVER_OBJ + "._connect") + @mock.patch(DRIVER_OBJ + "._get_target_iscsi_port") + def test_initialize_connection(self, mock_get_iscsi_port, mock_connection): + mock_get_iscsi_port.return_value = ISCSI_PORTS[0] + mock_connection.return_value = {"vol": VOLUME["name"] + "-cinder", + "lun": 1, + } + result = {"driver_volume_type": "iscsi", + "data": {"target_iqn": TARGET_IQN, + "target_portal": ISCSI_IPS[0] + ":" + TARGET_PORT, + "target_lun": 1, + "target_discovered": True, + "access_mode": "rw", + }, + } + real_result = self.driver.initialize_connection(VOLUME, CONNECTOR) + self.assertDictMatch(result, real_result) + mock_get_iscsi_port.assert_called_with() + mock_connection.assert_called_with(VOLUME, CONNECTOR) + self.assert_error_propagates([mock_get_iscsi_port, mock_connection], + self.driver.initialize_connection, + VOLUME, CONNECTOR) + + @mock.patch(DRIVER_OBJ + "._choose_target_iscsi_port") + @mock.patch(DRIVER_OBJ + "._run_iscsiadm_bare") + def test_get_target_iscsi_port(self, mock_iscsiadm, mock_choose_port): + self.driver._iscsi_port = ISCSI_PORTS[1] + self.assertEqual(self.driver._get_target_iscsi_port(), ISCSI_PORTS[1]) + mock_iscsiadm.assert_called_with(["-m", "discovery", + "-t", "sendtargets", + "-p", ISCSI_PORTS[1]["portal"]]) + self.assertFalse(mock_choose_port.called) + mock_iscsiadm.reset_mock() + mock_iscsiadm.side_effect = [processutils.ProcessExecutionError, None] + mock_choose_port.return_value = ISCSI_PORTS[2] + self.assertEqual(self.driver._get_target_iscsi_port(), ISCSI_PORTS[2]) + mock_choose_port.assert_called_with() + mock_iscsiadm.side_effect = processutils.ProcessExecutionError + self.assert_error_propagates([mock_choose_port], + self.driver._get_target_iscsi_port) + + @mock.patch(DRIVER_OBJ + "._run_iscsiadm_bare") + def test_choose_target_iscsi_port(self, mock_iscsiadm): + self.array.list_ports.return_value = PORTS_WITHOUT + self.assertRaises(exception.PureDriverException, + self.driver._choose_target_iscsi_port) + self.array.list_ports.return_value = PORTS_WITH + self.assertEqual(ISCSI_PORTS[0], + self.driver._choose_target_iscsi_port()) + self.assert_error_propagates([mock_iscsiadm, self.array.list_ports], + self.driver._choose_target_iscsi_port) + + @mock.patch(DRIVER_OBJ + "._get_host_name", autospec=True) + def test_connect(self, mock_host): + vol_name = VOLUME["name"] + "-cinder" + result = {"vol": vol_name, "lun": 1} + mock_host.return_value = HOST_NAME + self.array.connect_host.return_value = {"vol": vol_name, "lun": 1} + real_result = self.driver._connect(VOLUME, CONNECTOR) + self.assertEqual(result, real_result) + mock_host.assert_called_with(self.driver, CONNECTOR) + self.array.connect_host.assert_called_with(HOST_NAME, vol_name) + self.assert_error_propagates([mock_host, self.array.connect_host], + self.driver._connect, + VOLUME, CONNECTOR) + + def test_get_host_name(self): + good_host = {"name": HOST_NAME, + "iqn": ["another-wrong-iqn", INITIATOR_IQN]} + bad_host = {"name": "bad-host", "iqn": ["wrong-iqn"]} + self.array.list_hosts.return_value = [bad_host] + self.assertRaises(exception.PureDriverException, + self.driver._get_host_name, CONNECTOR) + self.array.list_hosts.return_value.append(good_host) + real_result = self.driver._get_host_name(CONNECTOR) + self.assertEqual(real_result, good_host["name"]) + self.assert_error_propagates([self.array.list_hosts], + self.driver._get_host_name, CONNECTOR) + + @mock.patch(DRIVER_OBJ + "._get_host_name", autospec=True) + def test_terminate_connection(self, mock_host): + vol_name = VOLUME["name"] + "-cinder" + mock_host.return_value = HOST_NAME + self.driver.terminate_connection(VOLUME, CONNECTOR) + self.array.disconnect_host.assert_called_with(HOST_NAME, vol_name) + self.assert_error_propagates( + [self.array.disconnect_host], + self.driver.terminate_connection, VOLUME, CONNECTOR) + + def test_get_volume_stats(self): + self.assertEqual(self.driver.get_volume_stats(), {}) + self.array.get_array.return_value = SPACE_INFO + result = {"volume_backend_name": VOLUME_BACKEND_NAME, + "vendor_name": "Pure Storage", + "driver_version": self.driver.VERSION, + "storage_protocol": "iSCSI", + "total_capacity_gb": TOTAL_SPACE, + "free_capacity_gb": FREE_SPACE, + "reserved_percentage": 0, + } + real_result = self.driver.get_volume_stats(refresh=True) + self.assertDictMatch(result, real_result) + self.assertDictMatch(result, self.driver._stats) + + def test_extend_volume(self): + vol_name = VOLUME["name"] + "-cinder" + self.driver.extend_volume(VOLUME, 3) + self.array.extend_volume.assert_called_with(vol_name, 3 * units.Gi) + self.assert_error_propagates([self.array.extend_volume], + self.driver.extend_volume, VOLUME, 3) + + +class FlashArrayBaseTestCase(test.TestCase): + + def setUp(self): + super(FlashArrayBaseTestCase, self).setUp() + array = FakeFlashArray() + array._target = TARGET + array._rest_version = REST_VERSION + array._root_url = "https://{0}/api/{1}/".format(TARGET, REST_VERSION) + array._api_token = API_TOKEN + self.array = array + + def assert_error_propagates(self, mocks, func, *args, **kwargs): + """Assert that errors from mocks propogate to func. + + Fail if exceptions raised by mocks are not seen when calling + func(*args, **kwargs). Ensure that we are really seeing exceptions + from the mocks by failing if just running func(*args, **kargs) raises + an exception itself. + """ + func(*args, **kwargs) + for mock_func in mocks: + mock_func.side_effect = exception.PureAPIException(reason="reason") + self.assertRaises(exception.PureAPIException, + func, *args, **kwargs) + mock_func.side_effect = None + + +class FlashArrayInitTestCase(FlashArrayBaseTestCase): + + @mock.patch(ARRAY_OBJ + "._start_session", autospec=True) + @mock.patch(ARRAY_OBJ + "._choose_rest_version", autospec=True) + @mock.patch(DRIVER_PATH + ".urllib2.build_opener", autospec=True) + def test_init(self, mock_build_opener, mock_choose, mock_start): + opener = mock.Mock() + mock_build_opener.return_value = opener + mock_choose.return_value = REST_VERSION + array = pure.FlashArray(TARGET, API_TOKEN) + mock_choose.assert_called_with(array) + mock_start.assert_called_with(array) + self.assertEqual(array._target, TARGET) + self.assertEqual(array._api_token, API_TOKEN) + self.assertEqual(array._rest_version, REST_VERSION) + self.assertIs(array._opener, opener) + self.assert_error_propagates([mock_choose, mock_start], + pure.FlashArray, TARGET, API_TOKEN) + + +class FlashArrayHttpRequestTestCase(FlashArrayBaseTestCase): + + def setUp(self): + super(FlashArrayHttpRequestTestCase, self).setUp() + self.method = "POST" + self.path = "path" + self.path_template = "https://{0}/api/{1}/{2}" + self.full_path = self.path_template.format(TARGET, REST_VERSION, + self.path) + self.headers = {"Content-Type": "application/json"} + self.data = {"list": [1, 2, 3]} + self.data_json = json.dumps(self.data) + self.response_json = '[{"hello": "world"}, "!"]' + self.result = json.loads(self.response_json) + self.error_msg = "error-msg" + self.response = mock.Mock(spec=["read", "readline", "info"]) + self.response.read.return_value = self.response_json + self.response.read.side_effect = None + self.response.info.return_value = self.headers + self.response.info.side_effect = None + + def make_call(self, method=None, path=None, data=None): + method = method if method else self.method + path = path if path else self.full_path + data = data if data else self.data_json + return mock.call(FakeRequest(method, path, headers=self.headers), data) + + def test_http_request_success(self): + self.array._opener.open.return_value = self.response + real_result = self.array._http_request( + self.method, self.path, self.data) + self.assertEqual(self.result, real_result) + self.assertEqual(self.array._opener.open.call_args_list, + [self.make_call()]) + + def test_http_request_401_error(self): + self.array._opener.open.return_value = self.response + error = urllib2.HTTPError(self.full_path, 401, self.error_msg, + None, self.response) + self.array._opener.open.side_effect = iter([error] + + [self.response] * 2) + real_result = self.array._http_request( + self.method, self.path, self.data) + self.assertEqual(self.result, real_result) + expected = [self.make_call(), + self.make_call( + "POST", self.path_template.format( + TARGET, REST_VERSION, "auth/session"), + json.dumps({"api_token": API_TOKEN})), + self.make_call()] + self.assertEqual(self.array._opener.open.call_args_list, expected) + self.array._opener.open.reset_mock() + self.array._opener.open.side_effect = iter([error, error]) + self.assertRaises(exception.PureAPIException, + self.array._http_request, + self.method, self.path, self.data) + self.array._opener.open.reset_mock() + self.array._opener.open.side_effect = iter([error, self.response, + error]) + self.assertRaises(exception.PureAPIException, + self.array._http_request, + self.method, self.path, self.data) + + @mock.patch(ARRAY_OBJ + "._choose_rest_version", autospec=True) + def test_http_request_450_error(self, mock_choose): + mock_choose.return_value = "1.1" + error = urllib2.HTTPError(self.full_path, 450, self.error_msg, + None, self.response) + self.array._opener.open.side_effect = iter([error, self.response]) + real_result = self.array._http_request( + self.method, self.path, self.data) + self.assertEqual(self.result, real_result) + expected = [self.make_call(), + self.make_call(path=self.path_template.format( + TARGET, "1.1", self.path))] + self.assertEqual(self.array._opener.open.call_args_list, expected) + mock_choose.assert_called_with(self.array) + self.array._opener.open.side_effect = error + self.assertRaises(exception.PureAPIException, + self.array._http_request, + self.method, self.path, self.data) + self.array._opener.open.reset_mock() + mock_choose.reset_mock() + self.array._opener.open.side_effect = error + mock_choose.side_effect = exception.PureAPIException(reason="reason") + self.assertRaises(exception.PureAPIException, + self.array._http_request, + self.method, self.path, self.data) + + def test_http_request_http_error(self): + self.array._opener.open.return_value = self.response + error = urllib2.HTTPError(self.full_path, 500, self.error_msg, + None, self.response) + self.array._opener.open.side_effect = error + self.assertRaises(exception.PureAPIException, + self.array._http_request, + self.method, self.path, self.data) + self.assertEqual(self.array._opener.open.call_args_list, + [self.make_call()]) + + def test_http_request_url_error(self): + self.array._opener.open.return_value = self.response + error = urllib2.URLError(self.error_msg) + self.array._opener.open.side_effect = error + # try/except used to ensure is instance of type but not subtype + try: + self.array._http_request(self.method, self.path, self.data) + except exception.PureDriverException as err: + self.assertFalse(isinstance(err, exception.PureAPIException)) + else: + self.assertTrue(False, "expected failure, but passed") + self.assertEqual(self.array._opener.open.call_args_list, + [self.make_call()]) + + def test_http_request_other_error(self): + self.array._opener.open.return_value = self.response + self.assert_error_propagates([self.array._opener.open], + self.array._http_request, + self.method, self.path, self.data) + + # Test with _http_requests rather than rest calls to ensure + # root_url change happens properly + def test_choose_rest_version(self): + response_string = '{"version": ["0.1", "1.3", "1.1", "1.0"]}' + self.response.read.return_value = response_string + self.array._opener.open.return_value = self.response + result = self.array._choose_rest_version() + self.assertEqual(result, "1.1") + self.array._opener.open.assert_called_with(FakeRequest( + "GET", "https://{0}/api/api_version".format(TARGET), + headers=self.headers), "null") + self.array._opener.open.reset_mock() + self.response.read.return_value = '{"version": ["0.1", "1.3"]}' + self.assertRaises(exception.PureDriverException, + self.array._choose_rest_version) + + +@mock.patch(ARRAY_OBJ + "._http_request", autospec=True) +class FlashArrayRESTTestCase(FlashArrayBaseTestCase): + + def setUp(self): + super(FlashArrayRESTTestCase, self).setUp() + self.kwargs = {"kwarg1": "val1", "kwarg2": "val2"} + self.result = "expected_return" + + def test_choose_rest_version(self, mock_req): + mock_req.return_value = {"version": ["0.1", "1.3", "1.1", "1.0"]} + self.assert_error_propagates([mock_req], + self.array._choose_rest_version) + + def test_start_session(self, mock_req): + self.array._start_session() + data = {"api_token": API_TOKEN} + mock_req.assert_called_with(self.array, "POST", "auth/session", + data, reestablish_session=False) + self.assert_error_propagates([mock_req], self.array._start_session) + + def test_get_array(self, mock_req): + mock_req.return_value = self.result + result = self.array.get_array(**self.kwargs) + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "GET", "array", self.kwargs) + self.assert_error_propagates([mock_req], self.array.get_array, + **self.kwargs) + + def test_create_volume(self, mock_req): + mock_req.return_value = self.result + result = self.array.create_volume("vol-name", "5G") + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "POST", "volume/vol-name", + {"size": "5G"}) + self.assert_error_propagates([mock_req], self.array.create_volume, + "vol-name", "5G") + + def test_copy_volume(self, mock_req): + mock_req.return_value = self.result + result = self.array.copy_volume("src-name", "dest-name") + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "POST", "volume/dest-name", + {"source": "src-name"}) + self.assert_error_propagates([mock_req], self.array.copy_volume, + "dest-name", "src-name") + + def test_create_snapshot(self, mock_req): + mock_req.return_value = [self.result, "second-arg"] + result = self.array.create_snapshot("vol-name", "suff") + self.assertEqual(result, self.result) + mock_req.assert_called_with( + self.array, "POST", "volume", + {"source": ["vol-name"], "suffix": "suff", "snap": True}) + self.assert_error_propagates([mock_req], self.array.create_snapshot, + "vol-name", "suff") + + def test_destroy_volume(self, mock_req): + mock_req.return_value = self.result + result = self.array.destroy_volume("vol-name") + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "DELETE", "volume/vol-name") + self.assert_error_propagates([mock_req], self.array.destroy_volume, + "vol-name") + + def test_extend_volume(self, mock_req): + mock_req.return_value = self.result + result = self.array.extend_volume("vol-name", "5G") + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "PUT", "volume/vol-name", + {"size": "5G", "truncate": False}) + self.assert_error_propagates([mock_req], self.array.extend_volume, + "vol-name", "5G") + + def test_list_hosts(self, mock_req): + mock_req.return_value = self.result + result = self.array.list_hosts(**self.kwargs) + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "GET", "host", self.kwargs) + self.assert_error_propagates([mock_req], self.array.list_hosts, + **self.kwargs) + + def test_connect_host(self, mock_req): + mock_req.return_value = self.result + result = self.array.connect_host("host-name", "vol-name", + **self.kwargs) + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "POST", + "host/host-name/volume/vol-name", + self.kwargs) + self.assert_error_propagates([mock_req], self.array.connect_host, + "host-name", "vol-name", **self.kwargs) + + def test_disconnect_host(self, mock_req): + mock_req.return_value = self.result + result = self.array.disconnect_host("host-name", "vol-name") + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "DELETE", + "host/host-name/volume/vol-name") + self.assert_error_propagates([mock_req], self.array.disconnect_host, + "host-name", "vol-name") + + def test_list_ports(self, mock_req): + mock_req.return_value = self.result + result = self.array.list_ports(**self.kwargs) + self.assertEqual(result, self.result) + mock_req.assert_called_with(self.array, "GET", "port", self.kwargs) + self.assert_error_propagates([mock_req], self.array.list_ports, + **self.kwargs) + + +class FakeFlashArray(pure.FlashArray): + + def __init__(self): + self._opener = mock.Mock() + + +class FakeRequest(urllib2.Request): + + def __init__(self, method, *args, **kwargs): + urllib2.Request.__init__(self, *args, **kwargs) + self.get_method = lambda: method + + def __eq__(self, other): + if not isinstance(other, urllib2.Request): + return False + return (self.get_method() == other.get_method() and + self.get_full_url() == other.get_full_url() and + self.header_items() == other.header_items()) + + def __ne__(self, other): + return not (self == other) diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py new file mode 100644 index 000000000..686b0bf02 --- /dev/null +++ b/cinder/volume/drivers/pure.py @@ -0,0 +1,397 @@ +# Copyright (c) 2014 Pure Storage, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +Volume driver for Pure Storage FlashArray storage system. + +This driver requires Purity version 3.4.0 or later. +""" + +import cookielib +import json +import urllib2 + +from oslo.config import cfg + +from cinder import exception +from cinder.openstack.common import excutils +from cinder.openstack.common.gettextutils import _ +from cinder.openstack.common import log as logging +from cinder.openstack.common import processutils +from cinder.openstack.common import units +from cinder import utils +from cinder.volume.drivers.san import san + +LOG = logging.getLogger(__name__) + +PURE_OPTS = [ + cfg.StrOpt("pure_api_token", default=None, + help="REST API authorization token."), +] + +CONF = cfg.CONF +CONF.register_opts(PURE_OPTS) + + +def _get_vol_name(volume): + """Return the name of the volume Purity will use.""" + return volume["name"] + "-cinder" + + +def _get_snap_name(snapshot): + """Return the name of the snapshot that Purity will use.""" + return "{0}-cinder.{1}".format(snapshot["volume_name"], + snapshot["name"]) + + +class PureISCSIDriver(san.SanISCSIDriver): + """Performs volume management on Pure Storage FlashArray.""" + + VERSION = "1.0.0" + + def __init__(self, *args, **kwargs): + execute = kwargs.pop("execute", utils.execute) + super(PureISCSIDriver, self).__init__(execute=execute, *args, **kwargs) + self.configuration.append_config_values(PURE_OPTS) + self._array = None + self._iscsi_port = None + self._backend_name = (self.configuration.volume_backend_name or + self.__class__.__name__) + + def do_setup(self, context): + """Performs driver initialization steps that could raise exceptions.""" + # Raises PureDriverException if unable to connect and PureAPIException + # if unable to authenticate. + self._array = FlashArray( + self.configuration.san_ip, + self.configuration.pure_api_token) + self._iscsi_port = self._choose_target_iscsi_port() + + def check_for_setup_error(self): + # Avoid inheriting check_for_setup_error from SanDriver, which checks + # for san_password or san_private_key, not relevant to our driver. + pass + + def create_volume(self, volume): + """Creates a volume.""" + LOG.debug("Enter PureISCSIDriver.create_volume.") + vol_name = _get_vol_name(volume) + vol_size = volume["size"] * units.Gi + self._array.create_volume(vol_name, vol_size) + LOG.debug("Leave PureISCSIDriver.create_volume.") + + def create_volume_from_snapshot(self, volume, snapshot): + """Creates a volume from a snapshot.""" + LOG.debug("Enter PureISCSIDriver.create_volume_from_snapshot.") + vol_name = _get_vol_name(volume) + snap_name = _get_snap_name(snapshot) + self._array.copy_volume(snap_name, vol_name) + self._extend_if_needed(vol_name, snapshot["volume_size"], + volume["size"]) + LOG.debug("Leave PureISCSIDriver.create_volume_from_snapshot.") + + def create_cloned_volume(self, volume, src_vref): + """Creates a clone of the specified volume.""" + LOG.debug("Enter PureISCSIDriver.create_cloned_volume.") + vol_name = _get_vol_name(volume) + src_name = _get_vol_name(src_vref) + self._array.copy_volume(src_name, vol_name) + self._extend_if_needed(vol_name, src_vref["size"], volume["size"]) + LOG.debug("Leave PureISCSIDriver.create_cloned_volume.") + + def _extend_if_needed(self, vol_name, src_size, vol_size): + """Extend the volume from size src_size to size vol_size.""" + if vol_size > src_size: + vol_size = vol_size * units.Gi + self._array.extend_volume(vol_name, vol_size) + + def delete_volume(self, volume): + """Deletes a volume.""" + LOG.debug("Enter PureISCSIDriver.delete_volume.") + vol_name = _get_vol_name(volume) + try: + self._array.destroy_volume(vol_name) + except exception.PureAPIException as err: + with excutils.save_and_reraise_exception as ctxt: + if err.kwargs["code"] == 400: + # This happens if the volume does not exist. + ctxt.reraise = False + LOG.error(_("Disconnection failed with message: {}" + ).format(err.msg)) + LOG.debug("Leave PureISCSIDriver.delete_volume.") + + def create_snapshot(self, snapshot): + """Creates a snapshot.""" + LOG.debug("Enter PureISCSIDriver.create_snapshot.") + vol_name, snap_suff = _get_snap_name(snapshot).split(".") + self._array.create_snapshot(vol_name, snap_suff) + LOG.debug("Leave PureISCSIDriver.create_snapshot.") + + def delete_snapshot(self, snapshot): + """Deletes a snapshot.""" + LOG.debug("Enter PureISCSIDriver.delete_snapshot.") + snap_name = _get_snap_name(snapshot) + try: + self._array.destroy_volume(snap_name) + except exception.PureAPIException as err: + with excutils.save_and_reraise_exception as ctxt: + if err.kwargs["code"] == 400: + # This happens if the snapshot does not exist. + ctxt.reraise = False + LOG.error(_("Disconnection failed with message: {}" + ).format(err.msg)) + LOG.debug("Leave PureISCSIDriver.delete_snapshot.") + + def initialize_connection(self, volume, connector): + """Allow connection to connector and return connection info.""" + LOG.debug("Enter PureISCSIDriver.initialize_connection.") + target_port = self._get_target_iscsi_port() + connection = self._connect(volume, connector) + properties = { + "driver_volume_type": "iscsi", + "data": { + "target_iqn": target_port["iqn"], + "target_portal": target_port["portal"], + "target_lun": connection["lun"], + "target_discovered": True, + "access_mode": "rw", + }, + } + LOG.debug("Leave PureISCSIDriver.initialize_connection. " + "Return value: " + str(properties)) + return properties + + def _get_target_iscsi_port(self): + """Return dictionary describing iSCSI-enabled port on target array.""" + try: + self._run_iscsiadm_bare(["-m", "discovery", "-t", "sendtargets", + "-p", self._iscsi_port["portal"]]) + except processutils.ProcessExecutionError as err: + LOG.warn(_("iSCSI discovery of port {0[name]} at {0[portal]} " + "failed with error: {1}").format(self._iscsi_port, + err.stderr)) + self._iscsi_port = self._choose_target_iscsi_port() + return self._iscsi_port + + def _choose_target_iscsi_port(self): + """Find a reachable iSCSI-enabled port on target array.""" + ports = self._array.list_ports() + iscsi_ports = [port for port in ports if port["iqn"]] + for port in iscsi_ports: + try: + self._run_iscsiadm_bare(["-m", "discovery", + "-t", "sendtargets", + "-p", port["portal"]]) + except processutils.ProcessExecutionError as err: + LOG.debug(("iSCSI discovery of port {0[name]} at {0[portal]} " + "failed with error: {1}").format(port, err.stderr)) + else: + LOG.info(_("Using port {0[name]} on the array at {0[portal]} " + "for iSCSI connectivity.").format(port)) + return port + raise exception.PureDriverException( + reason=_("No reachable iSCSI-enabled ports on target array.")) + + def _connect(self, volume, connector): + """Connect the host and volume; return dict describing connection.""" + host_name = self._get_host_name(connector) + vol_name = _get_vol_name(volume) + return self._array.connect_host(host_name, vol_name) + + def _get_host_name(self, connector): + """Return dictionary describing the Purity host with initiator IQN.""" + hosts = self._array.list_hosts() + for host in hosts: + if connector["initiator"] in host["iqn"]: + return host["name"] + raise exception.PureDriverException( + reason=(_("No host object on target array with IQN: ") + + connector["initiator"])) + + def terminate_connection(self, volume, connector, **kwargs): + """Terminate connection.""" + LOG.debug("Enter PureISCSIDriver.terminate_connection.") + vol_name = _get_vol_name(volume) + try: + host_name = self._get_host_name(connector) + self._array.disconnect_host(host_name, vol_name) + except exception.PureAPIException as err: + with excutils.save_and_reraise_exception as ctxt: + if err.kwargs["code"] == 400: + # This happens if the host and volume are not connected + ctxt.reraise = False + LOG.error(_("Disconnection failed with message: {}" + ).format(err.msg)) + LOG.debug("Leave PureISCSIDriver.terminate_connection.") + + def get_volume_stats(self, refresh=False): + """Return the current state of the volume service. + + If 'refresh' is True, run the update first. + """ + + LOG.debug("Enter PureISCSIDriver.get_volume_stats.") + if refresh: + LOG.debug("Updating volume stats.") + self._update_stats() + LOG.debug("Leave PureISCSIDriver.get_volume_stats.") + return self._stats + + def _update_stats(self): + """Set self._stats with relevant information.""" + info = self._array.get_array(space=True) + total = float(info["capacity"]) / units.Gi + free = float(info["capacity"] - info["total"]) / units.Gi + data = {"volume_backend_name": self._backend_name, + "vendor_name": "Pure Storage", + "driver_version": self.VERSION, + "storage_protocol": "iSCSI", + "total_capacity_gb": total, + "free_capacity_gb": free, + "reserved_percentage": 0, + } + self._stats = data + + def extend_volume(self, volume, new_size): + """Extend volume to new_size.""" + LOG.debug("Enter PureISCSIDriver.extend_volume.") + vol_name = _get_vol_name(volume) + new_size = new_size * units.Gi + self._array.extend_volume(vol_name, new_size) + LOG.debug("Leave PureISCSIDriver.extend_volume.") + + +class FlashArray(object): + """Wrapper for Pure Storage REST API.""" + SUPPORTED_REST_API_VERSIONS = ["1.2", "1.1", "1.0"] + + def __init__(self, target, api_token): + cookie_handler = urllib2.HTTPCookieProcessor(cookielib.CookieJar()) + self._opener = urllib2.build_opener(cookie_handler) + self._target = target + self._rest_version = self._choose_rest_version() + self._root_url = "https://{0}/api/{1}/".format(target, + self._rest_version) + self._api_token = api_token + self._start_session() + + def _http_request(self, method, path, data=None, reestablish_session=True): + """Perform HTTP request for REST API.""" + req = urllib2.Request(self._root_url + path, + headers={"Content-Type": "application/json"}) + req.get_method = lambda: method + body = json.dumps(data) + try: + # Raises urllib2.HTTPError if response code != 200 + response = self._opener.open(req, body) + except urllib2.HTTPError as err: + if (reestablish_session and err.code == 401): + self._start_session() + return self._http_request(method, path, data, + reestablish_session=False) + elif err.code == 450: + # Purity REST API version is bad + new_version = self._choose_rest_version() + if new_version == self._rest_version: + raise exception.PureAPIException( + code=err.code, + reason=(_("Unable to find usable REST API version. " + "Response from Pure Storage REST API: ") + + err.read())) + self._rest_version = new_version + self._root_url = "https://{0}/api/{1}/".format( + self._target, + self._rest_version) + return self._http_request(method, path, data) + else: + raise exception.PureAPIException(code=err.code, + reason=err.read()) + except urllib2.URLError as err: + # Error outside scope of HTTP status codes, + # e.g., unable to resolve domain name + raise exception.PureDriverException( + reason=_("Unable to connect to {0!r}. Check san_ip." + ).format(self._target)) + else: + content = response.read() + if "application/json" in response.info().get('Content-Type'): + return json.loads(content) + raise exception.PureAPIException( + reason=(_("Response not in JSON: ") + content)) + + def _choose_rest_version(self): + """Return a REST API version.""" + self._root_url = "https://{0}/api/".format(self._target) + data = self._http_request("GET", "api_version") + available_versions = data["version"] + available_versions.sort(reverse=True) + for version in available_versions: + if version in FlashArray.SUPPORTED_REST_API_VERSIONS: + return version + raise exception.PureDriverException( + reason=_("All REST API versions supported by this version of the " + "Pure Storage iSCSI driver are unavailable on array.")) + + def _start_session(self): + """Start a REST API session.""" + self._http_request("POST", "auth/session", + {"api_token": self._api_token}, + reestablish_session=False) + + def get_array(self, **kwargs): + """Return a dictionary containing information about the array.""" + return self._http_request("GET", "array", kwargs) + + def create_volume(self, name, size): + """Create a volume and return a dictionary describing it.""" + return self._http_request("POST", "volume/{0}".format(name), + {"size": size}) + + def copy_volume(self, source, dest): + """Clone a volume and return a dictionary describing the new volume.""" + return self._http_request("POST", "volume/{0}".format(dest), + {"source": source}) + + def create_snapshot(self, volume, suffix): + """Create a snapshot and return a dictionary describing it.""" + data = {"source": [volume], "suffix": suffix, "snap": True} + return self._http_request("POST", "volume", data)[0] + + def destroy_volume(self, volume): + """Destroy an existing volume or snapshot.""" + return self._http_request("DELETE", "volume/{0}".format(volume)) + + def extend_volume(self, volume, size): + """Extend a volume to a new, larger size.""" + return self._http_request("PUT", "volume/{0}".format(volume), + {"size": size, "truncate": False}) + + def list_hosts(self, **kwargs): + """Return a list of dictionaries describing each host.""" + return self._http_request("GET", "host", kwargs) + + def connect_host(self, host, volume, **kwargs): + """Create a connection between a host and a volume.""" + return self._http_request("POST", + "host/{0}/volume/{1}".format(host, volume), + kwargs) + + def disconnect_host(self, host, volume): + """Delete a connection between a host and a volume.""" + return self._http_request("DELETE", + "host/{0}/volume/{1}".format(host, volume)) + + def list_ports(self, **kwargs): + """Return a list of dictionaries describing ports.""" + return self._http_request("GET", "port", kwargs) diff --git a/etc/cinder/cinder.conf.sample b/etc/cinder/cinder.conf.sample index 93063ace7..e4c0b3ef4 100644 --- a/etc/cinder/cinder.conf.sample +++ b/etc/cinder/cinder.conf.sample @@ -1586,6 +1586,14 @@ #nimble_subnet_label=* +# +# Options defined in cinder.volume.drivers.pure +# + +# REST API authorization token. (string value) +#pure_api_token= + + # # Options defined in cinder.volume.drivers.rbd #