Merge "Introduce iSCSI driver for Pure Storage FlashArray"

This commit is contained in:
Jenkins 2014-08-04 23:14:21 +00:00 committed by Gerrit Code Review
commit dffe64b79c
4 changed files with 1046 additions and 0 deletions

View 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.')

632
cinder/tests/test_pure.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -1586,6 +1586,14 @@
#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
#