--- /dev/null
+# 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)
--- /dev/null
+# 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)