]> review.fuel-infra Code Review - openstack-build/cinder-build.git/commitdiff
Introduce iSCSI driver for Pure Storage FlashArray
authorVictor A. Ying <victor.ying@purestorage.com>
Mon, 28 Jul 2014 20:30:57 +0000 (13:30 -0700)
committerVictor A. Ying <victor.ying@purestorage.com>
Mon, 28 Jul 2014 22:45:01 +0000 (15:45 -0700)
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

cinder/exception.py
cinder/tests/test_pure.py [new file with mode: 0644]
cinder/volume/drivers/pure.py [new file with mode: 0644]
etc/cinder/cinder.conf.sample

index b72d9d08bd0195b443fb8f511a0ac7a24155e7c2..11f20522bcebed40d9f36a2d57d7e2475077ac25 100644 (file)
@@ -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 (file)
index 0000000..cf91837
--- /dev/null
@@ -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 (file)
index 0000000..686b0bf
--- /dev/null
@@ -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)
index 93063ace76c62d37158a1d572d37eb7f34697b79..e4c0b3ef4c575567244f0a31fe5f15a599bc5866 100644 (file)
 #nimble_subnet_label=*
 
 
+#
+# Options defined in cinder.volume.drivers.pure
+#
+
+# REST API authorization token. (string value)
+#pure_api_token=<None>
+
+
 #
 # Options defined in cinder.volume.drivers.rbd
 #