From 81d561effee1ba4de8d491ec53a8d3a1801ab745 Mon Sep 17 00:00:00 2001 From: Patrick East Date: Wed, 14 Jan 2015 15:07:16 -0800 Subject: [PATCH] Switch the PureISCSIDriver over to using the purestorage pypi module. All management api's will now go through the purestorage module, the interface is extremely similar to the original FlashArray object included in the cinder.volume.drivers.pure module. This allows for updates to the REST API and python code that uses it to not disrupt the cinder driver. Implements: blueprint pure-iscsi-purestorage-lib Change-Id: Ifcfb1c2337e5837f2121929c4788137d8bd1cf42 --- cinder/exception.py | 4 - cinder/tests/test_pure.py | 636 ++++++++-------------------------- cinder/volume/drivers/pure.py | 259 +++----------- 3 files changed, 197 insertions(+), 702 deletions(-) diff --git a/cinder/exception.py b/cinder/exception.py index 67a657fac..6015765c1 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -689,10 +689,6 @@ 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 index 46d26dd31..2a80dd77d 100644 --- a/cinder/tests/test_pure.py +++ b/cinder/tests/test_pure.py @@ -13,15 +13,16 @@ # License for the specific language governing permissions and limitations # under the License. -import json -import urllib2 - import mock from oslo.utils import units from oslo_concurrency import processutils from cinder import exception from cinder import test + + +import sys +sys.modules['purestorage'] = mock.Mock() from cinder.volume.drivers import pure DRIVER_PATH = "cinder.volume.drivers.pure" @@ -102,6 +103,16 @@ SPACE_INFO = {"capacity": TOTAL_SPACE * units.Gi, } +class FakePureStorageHTTPError(Exception): + def __init__(self, target=None, rest_version=None, code=None, + headers=None, text=None): + self.target = target + self.rest_version = rest_version + self.code = code + self.headers = headers + self.text = text + + class PureISCSIDriverTestCase(test.TestCase): def setUp(self): @@ -111,22 +122,36 @@ class PureISCSIDriverTestCase(test.TestCase): 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.array = mock.Mock() self.driver._array = self.array + self.purestorage_module = pure.purestorage + self.purestorage_module.PureHTTPError = FakePureStorageHTTPError - @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): + def test_do_setup(self, mock_choose_target_iscsi_port): mock_choose_target_iscsi_port.return_value = ISCSI_PORTS[0] - mock_array.return_value = self.array + self.purestorage_module.FlashArray.return_value = self.array + self.array.get_rest_version.return_value = \ + self.driver.SUPPORTED_REST_API_VERSIONS[0] self.driver.do_setup(None) - mock_array.assert_called_with(TARGET, API_TOKEN) + self.purestorage_module.FlashArray.assert_called_with( + TARGET, + api_token=API_TOKEN + ) self.assertEqual(self.array, self.driver._array) + self.assertEqual( + self.driver.SUPPORTED_REST_API_VERSIONS, + self.purestorage_module.FlashArray.supported_rest_versions + ) 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) + [ + self.purestorage_module.FlashArray, + mock_choose_target_iscsi_port + ], + self.driver.do_setup, None + ) def assert_error_propagates(self, mocks, func, *args, **kwargs): """Assert that errors from mocks propagate to func. @@ -262,30 +287,34 @@ class PureISCSIDriverTestCase(test.TestCase): vol_name) def test_delete_volume_already_deleted(self): - self.array.list_volume_hosts.side_effect = exception.PureAPIException( - code=400, reason="Volume does not exist") + self.array.list_volume_private_connections.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Volume does not exist" + ) self.driver.delete_volume(VOLUME) self.assertFalse(self.array.destroy_volume.called) - self.array.list_volume_hosts.side_effect = None - self.assert_error_propagates([self.array.destroy_volume], - self.driver.delete_volume, VOLUME) + # Testing case where array.destroy_volume returns an exception - # because volume already deleted - self.array.destroy_volume.side_effect = exception.PureAPIException( - code=400, reason="Volume does not exist") + # because volume has already been deleted + self.array.list_volume_private_connections.side_effect = None + self.array.list_volume_private_connections.return_value = {} + self.array.destroy_volume.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Volume does not exist" + ) self.driver.delete_volume(VOLUME) self.assertTrue(self.array.destroy_volume.called) - self.array.destroy_volume.side_effect = None - self.assert_error_propagates([self.array.destroy_volume], - self.driver.delete_volume, VOLUME) def test_delete_volume(self): vol_name = VOLUME["name"] + "-cinder" + self.array.list_volume_private_connections.return_value = {} self.driver.delete_volume(VOLUME) expected = [mock.call.destroy_volume(vol_name)] self.array.assert_has_calls(expected) - self.array.destroy_volume.side_effect = exception.PureAPIException( - code=400, reason="reason") + self.array.destroy_volume.side_effect = \ + self.purestorage_module.PureHTTPError(code=400, text="reason") self.driver.delete_snapshot(SNAPSHOT) self.array.destroy_volume.side_effect = None self.assert_error_propagates([self.array.destroy_volume], @@ -295,7 +324,7 @@ class PureISCSIDriverTestCase(test.TestCase): vol_name = VOLUME["name"] + "-cinder" host_name_a = "ha" host_name_b = "hb" - self.array.list_volume_hosts.return_value = [{ + self.array.list_volume_private_connections.return_value = [{ "host": host_name_a, "lun": 7, "name": vol_name, @@ -308,7 +337,7 @@ class PureISCSIDriverTestCase(test.TestCase): }] self.driver.delete_volume(VOLUME) - expected = [mock.call.list_volume_hosts(vol_name), + expected = [mock.call.list_volume_private_connections(vol_name), mock.call.disconnect_host(host_name_a, vol_name), mock.call.disconnect_host(host_name_b, vol_name), mock.call.destroy_volume(vol_name)] @@ -317,8 +346,10 @@ class PureISCSIDriverTestCase(test.TestCase): 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.array.create_snapshot.assert_called_with( + vol_name, + suffix=SNAPSHOT["name"] + ) self.assert_error_propagates([self.array.create_snapshot], self.driver.create_snapshot, SNAPSHOT) @@ -327,8 +358,8 @@ class PureISCSIDriverTestCase(test.TestCase): self.driver.delete_snapshot(SNAPSHOT) expected = [mock.call.destroy_volume(snap_name)] self.array.assert_has_calls(expected) - self.array.destroy_volume.side_effect = exception.PureAPIException( - code=400, reason="reason") + self.array.destroy_volume.side_effect = \ + self.purestorage_module.PureHTTPError(code=400, text="reason") self.driver.delete_snapshot(SNAPSHOT) self.array.destroy_volume.side_effect = None self.assert_error_propagates([self.array.destroy_volume], @@ -420,37 +451,46 @@ class PureISCSIDriverTestCase(test.TestCase): def test_connect_already_connected(self, mock_host): mock_host.return_value = PURE_HOST expected = {"host": PURE_HOST_NAME, "lun": 1} - self.array.list_volume_hosts.return_value = \ + self.array.list_volume_private_connections.return_value = \ [expected, {"host": "extra", "lun": 2}] - self.array.connect_host.side_effect = exception.PureAPIException( - code=400, reason="Connection already exists") + self.array.connect_host.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Connection already exists" + ) actual = self.driver._connect(VOLUME, CONNECTOR) self.assertEqual(expected, actual) self.assertTrue(self.array.connect_host.called) - self.assertTrue(self.array.list_volume_hosts) + self.assertTrue(self.array.list_volume_private_connections) @mock.patch(DRIVER_OBJ + "._get_host", autospec=True) def test_connect_already_connected_list_hosts_empty(self, mock_host): mock_host.return_value = PURE_HOST - self.array.list_volume_hosts.return_value = [] - self.array.connect_host.side_effect = exception.PureAPIException( - code=400, reason="Connection already exists") - self.assertRaises(exception.PureDriverException, - lambda: self.driver._connect(VOLUME, CONNECTOR)) + self.array.list_volume_private_connections.return_value = {} + self.array.connect_host.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Connection already exists" + ) + self.assertRaises(exception.PureDriverException, self.driver._connect, + VOLUME, CONNECTOR) self.assertTrue(self.array.connect_host.called) - self.assertTrue(self.array.list_volume_hosts) + self.assertTrue(self.array.list_volume_private_connections) @mock.patch(DRIVER_OBJ + "._get_host", autospec=True) def test_connect_already_connected_list_hosts_exception(self, mock_host): mock_host.return_value = PURE_HOST - self.array.list_volume_hosts.side_effect = \ - exception.PureAPIException(code=400, reason="") - self.array.connect_host.side_effect = exception.PureAPIException( - code=400, reason="Connection already exists") - self.assertRaises(exception.PureAPIException, - lambda: self.driver._connect(VOLUME, CONNECTOR)) + self.array.list_volume_private_connections.side_effect = \ + self.purestorage_module.PureHTTPError(code=400, text="") + self.array.connect_host.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Connection already exists" + ) + self.assertRaises(self.purestorage_module.PureHTTPError, + self.driver._connect, VOLUME, CONNECTOR) self.assertTrue(self.array.connect_host.called) - self.assertTrue(self.array.list_volume_hosts) + self.assertTrue(self.array.list_volume_private_connections) def test_get_host(self): good_host = PURE_HOST.copy() @@ -503,8 +543,8 @@ class PureISCSIDriverTestCase(test.TestCase): self.array.delete_host.assert_called_with(PURE_HOST_NAME) # Branch where connection is missing and the host is still deleted self.array.reset_mock() - self.array.disconnect_host.side_effect = exception.PureAPIException( - code=400, reason="reason") + self.array.disconnect_host.side_effect = \ + self.purestorage_module.PureHTTPError(code=400, text="reason") self.driver.terminate_connection(VOLUME, CONNECTOR) self.array.disconnect_host.assert_called_with(PURE_HOST_NAME, vol_name) self.array.list_host_connections.assert_called_with(PURE_HOST_NAME, @@ -512,9 +552,12 @@ class PureISCSIDriverTestCase(test.TestCase): self.array.delete_host.assert_called_with(PURE_HOST_NAME) # Branch where an unexpected exception occurs self.array.reset_mock() - self.array.disconnect_host.side_effect = exception.PureAPIException( - code=500, reason="unexpected exception") - self.assertRaises(exception.PureAPIException, + self.array.disconnect_host.side_effect = \ + self.purestorage_module.PureHTTPError( + code=500, + text="Some other error" + ) + self.assertRaises(self.purestorage_module.PureHTTPError, self.driver.terminate_connection, VOLUME, CONNECTOR) self.array.disconnect_host.assert_called_with(PURE_HOST_NAME, vol_name) self.assertFalse(self.array.list_host_connections.called) @@ -522,7 +565,7 @@ class PureISCSIDriverTestCase(test.TestCase): def test_get_volume_stats(self): self.assertEqual(self.driver.get_volume_stats(), {}) - self.array.get_array.return_value = SPACE_INFO + self.array.get.return_value = SPACE_INFO result = {"volume_backend_name": VOLUME_BACKEND_NAME, "vendor_name": "Pure Storage", "driver_version": self.driver.VERSION, @@ -619,40 +662,52 @@ class PureISCSIDriverTestCase(test.TestCase): self.driver.delete_consistencygroup(mock_context, mock_cgroup) expected_name = pure._get_pgroup_name_from_id(mock_cgroup.id) - self.array.delete_pgroup.assert_called_with(expected_name) + self.array.destroy_pgroup.assert_called_with(expected_name) self.assertEqual(expected_volumes, volumes) self.assertEqual(mock_cgroup['status'], model_update['status']) mock_delete_volume.assert_called_with(self.driver, mock_volume) - self.array.delete_pgroup.side_effect = exception.PureAPIException( - code=400, reason="Protection group has been destroyed.") + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Protection group has been destroyed." + ) self.driver.delete_consistencygroup(mock_context, mock_cgroup) - self.array.delete_pgroup.assert_called_with(expected_name) + self.array.destroy_pgroup.assert_called_with(expected_name) mock_delete_volume.assert_called_with(self.driver, mock_volume) - self.array.delete_pgroup.side_effect = exception.PureAPIException( - code=400, reason="Protection group does not exist") + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Protection group does not exist" + ) self.driver.delete_consistencygroup(mock_context, mock_cgroup) - self.array.delete_pgroup.assert_called_with(expected_name) + self.array.destroy_pgroup.assert_called_with(expected_name) mock_delete_volume.assert_called_with(self.driver, mock_volume) - self.array.delete_pgroup.side_effect = exception.PureAPIException( - code=400, reason="Some other error") - self.assertRaises(exception.PureAPIException, + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( + code=400, + text="Some other error" + ) + self.assertRaises(self.purestorage_module.PureHTTPError, self.driver.delete_consistencygroup, mock_context, mock_volume) - self.array.delete_pgroup.side_effect = exception.PureAPIException( - code=500, reason="Another different error") - self.assertRaises(exception.PureAPIException, + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( + code=500, + text="Another different error" + ) + self.assertRaises(self.purestorage_module.PureHTTPError, self.driver.delete_consistencygroup, mock_context, mock_volume) - self.array.delete_pgroup.side_effect = None + self.array.destroy_pgroup.side_effect = None self.assert_error_propagates( - [self.array.delete_pgroup], + [self.array.destroy_pgroup], self.driver.delete_consistencygroup, mock_context, mock_cgroup) def test_create_cgsnapshot(self): @@ -674,7 +729,8 @@ class PureISCSIDriverTestCase(test.TestCase): pure._get_pgroup_name_from_id(mock_cgsnap.consistencygroup_id) expected_snap_suffix = pure._get_pgroup_snap_suffix(mock_cgsnap) self.array.create_pgroup_snapshot\ - .assert_called_with(expected_pgroup_name, expected_snap_suffix) + .assert_called_with(expected_pgroup_name, + suffix=expected_snap_suffix) self.assertEqual({'status': 'available'}, model_update) self.assertEqual(expected_snaps, snapshots) self.assertEqual('available', mock_snap.status) @@ -700,455 +756,49 @@ class PureISCSIDriverTestCase(test.TestCase): model_update, snapshots = \ self.driver.delete_cgsnapshot(mock_context, mock_cgsnap) - self.array.delete_pgroup_snapshot.assert_called_with(snap_name) + self.array.destroy_pgroup.assert_called_with(snap_name) self.assertEqual({'status': mock_cgsnap.status}, model_update) self.assertEqual(expected_snaps, snapshots) self.assertEqual('deleted', mock_snap.status) - self.array.delete_pgroup_snapshot.side_effect = \ - exception.PureAPIException( + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( code=400, - reason="Protection group snapshot has been destroyed." + text="Protection group snapshot has been destroyed." ) self.driver.delete_cgsnapshot(mock_context, mock_cgsnap) - self.array.delete_pgroup_snapshot.assert_called_with(snap_name) + self.array.destroy_pgroup.assert_called_with(snap_name) - self.array.delete_pgroup_snapshot.side_effect = \ - exception.PureAPIException( + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( code=400, - reason="Protection group snapshot does not exist" + text="Protection group snapshot does not exist" ) self.driver.delete_cgsnapshot(mock_context, mock_cgsnap) - self.array.delete_pgroup_snapshot.assert_called_with(snap_name) + self.array.destroy_pgroup.assert_called_with(snap_name) - self.array.delete_pgroup_snapshot.side_effect = \ - exception.PureAPIException( + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( code=400, - reason="Some other error" + text="Some other error" ) - self.assertRaises(exception.PureAPIException, + self.assertRaises(self.purestorage_module.PureHTTPError, self.driver.delete_cgsnapshot, mock_context, mock_cgsnap) - self.array.delete_pgroup_snapshot.side_effect = \ - exception.PureAPIException( + self.array.destroy_pgroup.side_effect = \ + self.purestorage_module.PureHTTPError( code=500, - reason="Another different error" + text="Another different error" ) - self.assertRaises(exception.PureAPIException, + self.assertRaises(self.purestorage_module.PureHTTPError, self.driver.delete_cgsnapshot, mock_context, mock_cgsnap) - self.array.delete_pgroup_snapshot.side_effect = None + self.array.destroy_pgroup.side_effect = None self.assert_error_propagates( - [self.array.delete_pgroup_snapshot], + [self.array.destroy_pgroup], self.driver.delete_cgsnapshot, mock_context, mock_cgsnap) - - -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://%s/api/%s/" % (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 propagate 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://%s/api/%s/%s" - self.full_path = self.path_template % (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 % - (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 % - (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.4", "1.3", "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.3") - self.array._opener.open.assert_called_with(FakeRequest( - "GET", "https://%s/api/api_version" % TARGET, - headers=self.headers), "null") - self.array._opener.open.reset_mock() - self.response.read.return_value = '{"version": ["0.1", "1.4"]}' - 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_create_host(self, mock_req): - mock_req.return_value = self.result - host_name = "host1" - params = {'iqnlist': ['iqn1']} - result = self.array.create_host(host_name, iqnlist=['iqn1']) - self.assertEqual(result, self.result) - mock_req.assert_called_with(self.array, "POST", "host/" + host_name, - params) - self.assert_error_propagates([mock_req], self.array.create_host, - host_name, iqnlist=['iqn1']) - - def test_delete_host(self, mock_req): - mock_req.return_value = self.result - host_name = "host1" - result = self.array.delete_host(host_name) - self.assertEqual(result, self.result) - mock_req.assert_called_with(self.array, "DELETE", "host/" + host_name) - self.assert_error_propagates([mock_req], self.array.delete_host, - host_name) - - 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) - - def test_list_volume_hosts(self, mock_req): - mock_req.return_value = self.result - result = self.array.list_volume_hosts("vol-name") - self.assertEqual(result, self.result) - mock_req.assert_called_with(self.array, "GET", "volume/vol-name/host") - self.assert_error_propagates([mock_req], self.array.list_volume_hosts, - "vol-name") - - def test_create_pgroup(self, mock_req): - mock_req.return_value = self.result - pgroup_name = "cgroup_id" - result = self.array.create_pgroup(pgroup_name) - self.assertEqual(self.result, result) - req_url = "pgroup/" + pgroup_name - mock_req.assert_called_with(self.array, "POST", req_url) - self.assert_error_propagates([mock_req], self.array.create_pgroup, - pgroup_name) - - def test_delete_pgroup(self, mock_req): - mock_req.return_value = self.result - pgroup_name = "cgroup_id" - result = self.array.delete_pgroup(pgroup_name) - self.assertEqual(self.result, result) - req_url = "pgroup/" + pgroup_name - mock_req.assert_called_with(self.array, "DELETE", req_url) - self.assert_error_propagates([mock_req], self.array.delete_pgroup, - pgroup_name) - - def test_create_pgroup_snapshot(self, mock_req): - mock_req.return_value = self.result - pgroup_name = "cgroup_id" - snap_suffix = "snap_suffix" - result = self.array.create_pgroup_snapshot(pgroup_name, snap_suffix) - self.assertEqual(self.result, result) - expected_params = { - "snap": True, - "suffix": snap_suffix, - "source": [pgroup_name] - } - mock_req.assert_called_with(self.array, "POST", "pgroup", - expected_params) - self.assert_error_propagates([mock_req], - self.array.create_pgroup_snapshot, - pgroup_name, snap_suffix) - - def test_delete_pgroup_snapshot(self, mock_req): - mock_req.return_value = self.result - snapshot_name = "snap1" - result = self.array.delete_pgroup_snapshot(snapshot_name) - self.assertEqual(self.result, result) - req_url = "pgroup/" + snapshot_name - mock_req.assert_called_with(self.array, "DELETE", req_url) - self.assert_error_propagates([mock_req], - self.array.delete_pgroup_snapshot, - snapshot_name) - - def test_add_volume_to_pgroup(self, mock_req): - mock_req.return_value = self.result - pgroup_name = "cgroup_id" - volume_name = "myvol-1" - expected_params = {"addvollist": [volume_name]} - result = self.array.add_volume_to_pgroup(pgroup_name, volume_name) - self.assertEqual(self.result, result) - req_url = "pgroup/" + pgroup_name - mock_req.assert_called_with(self.array, "PUT", req_url, - expected_params) - self.assert_error_propagates([mock_req], - self.array.add_volume_to_pgroup, - pgroup_name, volume_name) - - -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 index 37710c4de..6ba3e789c 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -18,10 +18,7 @@ Volume driver for Pure Storage FlashArray storage system. This driver requires Purity version 3.4.0 or later. """ -import cookielib -import json import re -import urllib2 import uuid from oslo.config import cfg @@ -35,6 +32,11 @@ from cinder.openstack.common import log as logging from cinder import utils from cinder.volume.drivers.san import san +try: + import purestorage +except ImportError: + purestorage = None + LOG = logging.getLogger(__name__) PURE_OPTS = [ @@ -96,7 +98,9 @@ def _generate_purity_host_name(name): class PureISCSIDriver(san.SanISCSIDriver): """Performs volume management on Pure Storage FlashArray.""" - VERSION = "2.0.1" + VERSION = "2.0.3" + + SUPPORTED_REST_API_VERSIONS = ['1.2', '1.3', '1.4'] def __init__(self, *args, **kwargs): execute = kwargs.pop("execute", utils.execute) @@ -109,11 +113,18 @@ class PureISCSIDriver(san.SanISCSIDriver): def do_setup(self, context): """Performs driver initialization steps that could raise exceptions.""" - # Raises PureDriverException if unable to connect and PureAPIException + if purestorage is None: + msg = _("Missing 'purestorage' python module, ensure the library" + " is installed and available.") + raise exception.PureDriverException(msg) + + # Raises PureDriverException if unable to connect and PureHTTPError # if unable to authenticate. - self._array = FlashArray( + purestorage.FlashArray.supported_rest_versions = \ + self.SUPPORTED_REST_API_VERSIONS + self._array = purestorage.FlashArray( self.configuration.san_ip, - self.configuration.pure_api_token) + api_token=self.configuration.pure_api_token) self._iscsi_port = self._choose_target_iscsi_port() def check_for_setup_error(self): @@ -181,26 +192,27 @@ class PureISCSIDriver(san.SanISCSIDriver): LOG.debug("Enter PureISCSIDriver.delete_volume.") vol_name = _get_vol_name(volume) try: - connected_hosts = self._array.list_volume_hosts(vol_name) + connected_hosts = \ + self._array.list_volume_private_connections(vol_name) for host_info in connected_hosts: host_name = host_info["host"] self._disconnect_host(host_name, vol_name) self._array.destroy_volume(vol_name) - except exception.PureAPIException as err: + except purestorage.PureHTTPError as err: with excutils.save_and_reraise_exception() as ctxt: - if err.kwargs["code"] == 400 and \ - ERR_MSG_NOT_EXIST in err.msg: + if err.code == 400 and \ + ERR_MSG_NOT_EXIST in err.text: # Happens if the volume does not exist. ctxt.reraise = False LOG.warn(_LW("Volume deletion failed with message: %s"), - err.msg) + err.text) 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) + self._array.create_snapshot(vol_name, suffix=snap_suff) LOG.debug("Leave PureISCSIDriver.create_snapshot.") def delete_snapshot(self, snapshot): @@ -209,13 +221,13 @@ class PureISCSIDriver(san.SanISCSIDriver): snap_name = _get_snap_name(snapshot) try: self._array.destroy_volume(snap_name) - except exception.PureAPIException as err: + except purestorage.PureHTTPError as err: with excutils.save_and_reraise_exception() as ctxt: - if err.kwargs["code"] == 400: + if err.code == 400: # Happens if the snapshot does not exist. ctxt.reraise = False LOG.error(_LE("Snapshot deletion failed with message:" - " %s"), err.msg) + " %s"), err.text) LOG.debug("Leave PureISCSIDriver.delete_snapshot.") def initialize_connection(self, volume, connector): @@ -292,16 +304,17 @@ class PureISCSIDriver(san.SanISCSIDriver): try: connection = self._array.connect_host(host_name, vol_name) - except exception.PureAPIException as err: + except purestorage.PureHTTPError as err: with excutils.save_and_reraise_exception() as ctxt: - if (err.kwargs["code"] == 400 and - "Connection already exists" in err.msg): + if (err.code == 400 and + "Connection already exists" in err.text): # Happens if the volume is already connected to the host. ctxt.reraise = False LOG.warn(_LW("Volume connection already exists with " - "message: %s") % err.msg) + "message: %s"), err.text) # Get the info for the existing connection - connected_hosts = self._array.list_volume_hosts(vol_name) + connected_hosts = \ + self._array.list_volume_private_connections(vol_name) for host_info in connected_hosts: if host_info["host"] == host_name: connection = host_info @@ -336,13 +349,13 @@ class PureISCSIDriver(san.SanISCSIDriver): LOG.debug("Enter PureISCSIDriver._disconnect_host.") try: self._array.disconnect_host(host_name, vol_name) - except exception.PureAPIException as err: + except purestorage.PureHTTPError as err: with excutils.save_and_reraise_exception() as ctxt: - if err.kwargs["code"] == 400: + if err.code == 400: # Happens if the host and volume are not connected. ctxt.reraise = False LOG.error(_LE("Disconnection failed with message: " - "%(msg)s."), {"msg": err.msg}) + "%(msg)s."), {"msg": err.text}) if (GENERATED_NAME.match(host_name) and not self._array.list_host_connections(host_name, private=True)): @@ -366,7 +379,7 @@ class PureISCSIDriver(san.SanISCSIDriver): def _update_stats(self): """Set self._stats with relevant information.""" - info = self._array.get_array(space=True) + info = self._array.get(space=True) total = float(info["capacity"]) / units.Gi free = float(info["capacity"] - info["total"]) / units.Gi data = {"volume_backend_name": self._backend_name, @@ -390,7 +403,7 @@ class PureISCSIDriver(san.SanISCSIDriver): def _add_volume_to_consistency_group(self, consistencygroup_id, vol_name): pgroup_name = _get_pgroup_name_from_id(consistencygroup_id) - self._array.add_volume_to_pgroup(pgroup_name, vol_name) + self._array.set_pgroup(pgroup_name, addvollist=[vol_name]) def create_consistencygroup(self, context, group): """Creates a consistencygroup.""" @@ -408,17 +421,17 @@ class PureISCSIDriver(san.SanISCSIDriver): LOG.debug("Enter PureISCSIDriver.delete_consistencygroup") try: - self._array.delete_pgroup(_get_pgroup_name_from_id(group.id)) - except exception.PureAPIException as err: + self._array.destroy_pgroup(_get_pgroup_name_from_id(group.id)) + except purestorage.PureHTTPError as err: with excutils.save_and_reraise_exception() as ctxt: - if (err.kwargs["code"] == 400 and - (ERR_MSG_PENDING_ERADICATION in err.msg or - ERR_MSG_NOT_EXIST in err.msg)): + if (err.code == 400 and + (ERR_MSG_PENDING_ERADICATION in err.text or + ERR_MSG_NOT_EXIST in err.text)): # Treat these as a "success" case since we are trying # to delete them anyway. ctxt.reraise = False LOG.warning(_LW("Unable to delete Protection Group: %s"), - err.msg) + err.text) volumes = self.db.volume_get_all_by_group(context, group.id) @@ -437,7 +450,7 @@ class PureISCSIDriver(san.SanISCSIDriver): pgroup_name = _get_pgroup_name_from_id(cgsnapshot.consistencygroup_id) pgsnap_suffix = _get_pgroup_snap_suffix(cgsnapshot) - self._array.create_pgroup_snapshot(pgroup_name, pgsnap_suffix) + self._array.create_pgroup_snapshot(pgroup_name, suffix=pgsnap_suffix) snapshots = self.db.snapshot_get_all_for_cgsnapshot( context, cgsnapshot.id) @@ -457,17 +470,19 @@ class PureISCSIDriver(san.SanISCSIDriver): pgsnap_name = _get_pgroup_snap_name(cgsnapshot) try: - self._array.delete_pgroup_snapshot(pgsnap_name) - except exception.PureAPIException as err: + # FlashArray.destroy_pgroup is also used for deleting + # pgroup snapshots. The underlying REST API is identical. + self._array.destroy_pgroup(pgsnap_name) + except purestorage.PureHTTPError as err: with excutils.save_and_reraise_exception() as ctxt: - if (err.kwargs["code"] == 400 and - (ERR_MSG_PENDING_ERADICATION in err.msg or - ERR_MSG_NOT_EXIST in err.msg)): + if (err.code == 400 and + (ERR_MSG_PENDING_ERADICATION in err.text or + ERR_MSG_NOT_EXIST in err.text)): # Treat these as a "success" case since we are trying # to delete them anyway. ctxt.reraise = False LOG.warning(_LW("Unable to delete Protection Group " - "Snapshot: %s"), err.msg) + "Snapshot: %s"), err.text) snapshots = self.db.snapshot_get_all_for_cgsnapshot( context, cgsnapshot.id) @@ -479,169 +494,3 @@ class PureISCSIDriver(san.SanISCSIDriver): LOG.debug("Leave PureISCSIDriver.delete_cgsnapshot") return model_update, snapshots - - -class FlashArray(object): - """Wrapper for Pure Storage REST API.""" - SUPPORTED_REST_API_VERSIONS = ["1.3", "1.2"] - - 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://%s/api/%s/" % (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: %s") % - err.read())) - self._rest_version = new_version - self._root_url = "https://%s/api/%s/" % (self._target, - self._rest_version) - return self._http_request(method, path, data) - else: - raise exception.PureAPIException( - code=err.code, - reason=_("exception:%s") % 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 %r. Check san_ip.") % - 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: %s") % content)) - - def _choose_rest_version(self): - """Return a REST API version.""" - self._root_url = "https://%s/api/" % 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/%s" % 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/%s" % 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/%s" % volume) - - def extend_volume(self, volume, size): - """Extend a volume to a new, larger size.""" - return self._http_request("PUT", "volume/%s" % 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 list_host_connections(self, host, **kwargs): - """Return a list of dictionaries describing connected volumes.""" - return self._http_request("GET", "host/%s/volume" % host, kwargs) - - def create_host(self, host, **kwargs): - """Create a host.""" - return self._http_request("POST", "host/%s" % host, kwargs) - - def delete_host(self, host): - """Delete a host.""" - return self._http_request("DELETE", "host/%s" % host) - - def connect_host(self, host, volume, **kwargs): - """Create a connection between a host and a volume.""" - return self._http_request("POST", - "host/%s/volume/%s" % (host, volume), - kwargs) - - def disconnect_host(self, host, volume): - """Delete a connection between a host and a volume.""" - return self._http_request("DELETE", - "host/%s/volume/%s" % (host, volume)) - - def set_host(self, host, **kwargs): - """Set an attribute of a host.""" - return self._http_request("PUT", "host/%s" % host, kwargs) - - def list_ports(self, **kwargs): - """Return a list of dictionaries describing ports.""" - return self._http_request("GET", "port", kwargs) - - def list_volume_hosts(self, volume): - """Return a list of dictionaries describing connected hosts.""" - return self._http_request("GET", "volume/%s/host" % volume) - - def create_pgroup(self, name): - return self._http_request("POST", "pgroup/%s" % name) - - def delete_pgroup(self, name): - return self._http_request("DELETE", "pgroup/%s" % name) - - def create_pgroup_snapshot(self, pgroup_name, pgsnapshot_suffix): - params = { - "snap": True, - "suffix": pgsnapshot_suffix, - "source": [pgroup_name] - } - return self._http_request("POST", "pgroup", params) - - def delete_pgroup_snapshot(self, name): - return self._http_request("DELETE", "pgroup/%s" % name) - - def add_volume_to_pgroup(self, pgroup_name, volume_name): - return self._http_request("PUT", "pgroup/%s" % pgroup_name, - {"addvollist": [volume_name]}) -- 2.45.2