Add automatic creation and deletion of Purity hosts for PureISCSIDriver

The driver will now be responsible to manage hosts for new initiators
which do not already have a host created for them. This will allow for
backwards compatibility with the previous version that relied on hosts
being pre-configured for use by Cinder.


Implements: blueprint pure-iscsi-automatic-host-creation
Change-Id: I04b554e1a21128d55eddc21afda7108a868e4248
This commit is contained in:
Patrick East 2014-08-22 16:43:20 -07:00
parent 3c52d06611
commit ccb39178eb
2 changed files with 155 additions and 45 deletions

View File

@ -33,7 +33,13 @@ API_TOKEN = "12345678-abcd-1234-abcd-1234567890ab"
VOLUME_BACKEND_NAME = "Pure_iSCSI" VOLUME_BACKEND_NAME = "Pure_iSCSI"
PORT_NAMES = ["ct0.eth2", "ct0.eth3", "ct1.eth2", "ct1.eth3"] 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))] ISCSI_IPS = ["10.0.0." + str(i + 1) for i in range(len(PORT_NAMES))]
HOST_NAME = "pure-host" HOSTNAME = "computenode1"
PURE_HOST_NAME = pure._generate_purity_host_name(HOSTNAME)
PURE_HOST = {"name": PURE_HOST_NAME,
"hgroup": None,
"iqn": [],
"wwn": [],
}
REST_VERSION = "1.2" REST_VERSION = "1.2"
VOLUME_ID = "abcdabcd-1234-abcd-1234-abcdeffedcba" VOLUME_ID = "abcdabcd-1234-abcd-1234-abcdeffedcba"
VOLUME = {"name": "volume-" + VOLUME_ID, VOLUME = {"name": "volume-" + VOLUME_ID,
@ -62,7 +68,7 @@ SNAPSHOT = {"name": "snapshot-" + SNAPSHOT_ID,
"display_name": "fake_snapshot", "display_name": "fake_snapshot",
} }
INITIATOR_IQN = "iqn.1993-08.org.debian:01:222" INITIATOR_IQN = "iqn.1993-08.org.debian:01:222"
CONNECTOR = {"initiator": INITIATOR_IQN} CONNECTOR = {"initiator": INITIATOR_IQN, "host": HOSTNAME}
TARGET_IQN = "iqn.2010-06.com.purestorage:flasharray.12345abc" TARGET_IQN = "iqn.2010-06.com.purestorage:flasharray.12345abc"
TARGET_PORT = "3260" TARGET_PORT = "3260"
ISCSI_PORTS = [{"name": name, ISCSI_PORTS = [{"name": name,
@ -129,6 +135,19 @@ class PureISCSIDriverTestCase(test.TestCase):
func, *args, **kwargs) func, *args, **kwargs)
mock_func.side_effect = None mock_func.side_effect = None
def test_generate_purity_host_name(self):
generate = pure._generate_purity_host_name
result = generate("really-long-string-thats-a-bit-too-long")
self.assertTrue(result.startswith("really-long-string-that-"))
self.assertTrue(result.endswith("-cinder"))
self.assertEqual(len(result), 63)
self.assertTrue(pure.GENERATED_NAME.match(result))
result = generate("!@#$%^-invalid&*")
self.assertTrue(result.startswith("invalid---"))
self.assertTrue(result.endswith("-cinder"))
self.assertEqual(len(result), 49)
self.assertTrue(pure.GENERATED_NAME.match(result))
def test_create_volume(self): def test_create_volume(self):
self.driver.create_volume(VOLUME) self.driver.create_volume(VOLUME)
self.array.create_volume.assert_called_with( self.array.create_volume.assert_called_with(
@ -244,7 +263,6 @@ class PureISCSIDriverTestCase(test.TestCase):
"-t", "sendtargets", "-t", "sendtargets",
"-p", ISCSI_PORTS[1]["portal"]]) "-p", ISCSI_PORTS[1]["portal"]])
self.assertFalse(mock_choose_port.called) self.assertFalse(mock_choose_port.called)
mock_iscsiadm.reset_mock()
mock_iscsiadm.side_effect = [processutils.ProcessExecutionError, None] mock_iscsiadm.side_effect = [processutils.ProcessExecutionError, None]
mock_choose_port.return_value = ISCSI_PORTS[2] mock_choose_port.return_value = ISCSI_PORTS[2]
self.assertEqual(self.driver._get_target_iscsi_port(), ISCSI_PORTS[2]) self.assertEqual(self.driver._get_target_iscsi_port(), ISCSI_PORTS[2])
@ -264,51 +282,94 @@ class PureISCSIDriverTestCase(test.TestCase):
self.assert_error_propagates([mock_iscsiadm, self.array.list_ports], self.assert_error_propagates([mock_iscsiadm, self.array.list_ports],
self.driver._choose_target_iscsi_port) self.driver._choose_target_iscsi_port)
@mock.patch(DRIVER_OBJ + "._get_host_name", autospec=True) @mock.patch(DRIVER_OBJ + "._get_host", autospec=True)
def test_connect(self, mock_host): @mock.patch(DRIVER_PATH + "._generate_purity_host_name", autospec=True)
def test_connect(self, mock_generate, mock_host):
vol_name = VOLUME["name"] + "-cinder" vol_name = VOLUME["name"] + "-cinder"
result = {"vol": vol_name, "lun": 1} result = {"vol": vol_name, "lun": 1}
mock_host.return_value = HOST_NAME # Branch where host already exists
mock_host.return_value = PURE_HOST
self.array.connect_host.return_value = {"vol": vol_name, "lun": 1} self.array.connect_host.return_value = {"vol": vol_name, "lun": 1}
real_result = self.driver._connect(VOLUME, CONNECTOR) real_result = self.driver._connect(VOLUME, CONNECTOR)
self.assertEqual(result, real_result) self.assertEqual(result, real_result)
mock_host.assert_called_with(self.driver, CONNECTOR) mock_host.assert_called_with(self.driver, CONNECTOR)
self.array.connect_host.assert_called_with(HOST_NAME, vol_name) self.assertFalse(mock_generate.called)
self.assert_error_propagates([mock_host, self.array.connect_host], self.assertFalse(self.array.create_host.called)
self.driver._connect, self.array.connect_host.assert_called_with(PURE_HOST_NAME, vol_name)
VOLUME, CONNECTOR) # Branch where new host is created
mock_host.return_value = None
mock_generate.return_value = PURE_HOST_NAME
real_result = self.driver._connect(VOLUME, CONNECTOR)
mock_host.assert_called_with(self.driver, CONNECTOR)
mock_generate.assert_called_with(HOSTNAME)
self.array.create_host.assert_called_with(PURE_HOST_NAME,
iqnlist=[INITIATOR_IQN])
self.assertEqual(result, real_result)
# Branch where host is needed
mock_generate.reset_mock()
self.array.reset_mock()
self.assert_error_propagates(
[mock_host, mock_generate, self.array.connect_host,
self.array.create_host],
self.driver._connect, VOLUME, CONNECTOR)
def test_get_host_name(self): def test_get_host(self):
good_host = {"name": HOST_NAME, good_host = PURE_HOST.copy()
"iqn": ["another-wrong-iqn", INITIATOR_IQN]} good_host.update(iqn=["another-wrong-iqn", INITIATOR_IQN])
bad_host = {"name": "bad-host", "iqn": ["wrong-iqn"]} bad_host = {"name": "bad-host", "iqn": ["wrong-iqn"]}
self.array.list_hosts.return_value = [bad_host] self.array.list_hosts.return_value = [bad_host]
self.assertRaises(exception.PureDriverException, real_result = self.driver._get_host(CONNECTOR)
self.driver._get_host_name, CONNECTOR) self.assertIs(real_result, None)
self.array.list_hosts.return_value.append(good_host) self.array.list_hosts.return_value.append(good_host)
real_result = self.driver._get_host_name(CONNECTOR) real_result = self.driver._get_host(CONNECTOR)
self.assertEqual(real_result, good_host["name"]) self.assertEqual(real_result, good_host)
self.assert_error_propagates([self.array.list_hosts], self.assert_error_propagates([self.array.list_hosts],
self.driver._get_host_name, CONNECTOR) self.driver._get_host, CONNECTOR)
@mock.patch(DRIVER_OBJ + "._get_host_name", autospec=True) @mock.patch(DRIVER_OBJ + "._get_host", autospec=True)
def test_terminate_connection(self, mock_host): def test_terminate_connection(self, mock_host):
vol_name = VOLUME["name"] + "-cinder" vol_name = VOLUME["name"] + "-cinder"
mock_host.return_value = HOST_NAME mock_host.return_value = {"name": "some-host"}
# Branch with manually created host
self.driver.terminate_connection(VOLUME, CONNECTOR) self.driver.terminate_connection(VOLUME, CONNECTOR)
self.array.disconnect_host.assert_called_with(HOST_NAME, vol_name) self.array.disconnect_host.assert_called_with("some-host", vol_name)
self.assertFalse(self.array.list_host_connections.called)
self.assertFalse(self.array.delete_host.called)
# Branch with host added to host group
self.array.reset_mock()
mock_host.return_value = PURE_HOST.copy()
mock_host.return_value.update(hgroup="some-group")
self.driver.terminate_connection(VOLUME, CONNECTOR)
self.array.disconnect_host.assert_called_with(PURE_HOST_NAME, vol_name)
self.assertFalse(self.array.list_host_connections.called)
self.assertFalse(self.array.delete_host.called)
# Branch with host still having connected volumes
self.array.reset_mock()
self.array.list_host_connections.return_value = [
{"lun": 2, "name": PURE_HOST_NAME, "vol": "some-vol"}]
mock_host.return_value = PURE_HOST
self.driver.terminate_connection(VOLUME, CONNECTOR)
self.array.disconnect_host.assert_called_with(PURE_HOST_NAME, vol_name)
self.array.list_host_connections.assert_called_with(PURE_HOST_NAME,
private=True)
self.assertFalse(self.array.delete_host.called)
# Branch where host gets deleted
self.array.reset_mock()
self.array.list_host_connections.return_value = []
self.driver.terminate_connection(VOLUME, CONNECTOR)
self.array.disconnect_host.assert_called_with(PURE_HOST_NAME, vol_name)
self.array.list_host_connections.assert_called_with(PURE_HOST_NAME,
private=True)
self.array.delete_host.assert_called_with(PURE_HOST_NAME)
# Branch where connection is missing and the host is still deleted
self.array.reset_mock()
self.array.disconnect_host.side_effect = exception.PureAPIException( self.array.disconnect_host.side_effect = exception.PureAPIException(
code=400, reason="reason") code=400, reason="reason")
self.driver.terminate_connection(VOLUME, CONNECTOR) self.driver.terminate_connection(VOLUME, CONNECTOR)
self.array.disconnect_host.assert_called_with(HOST_NAME, vol_name) self.array.disconnect_host.assert_called_with(PURE_HOST_NAME, vol_name)
self.array.disconnect_host.side_effect = None self.array.list_host_connections.assert_called_with(PURE_HOST_NAME,
self.array.disconnect_host.reset_mock() private=True)
mock_host.side_effect = exception.PureDriverException(reason="reason") self.array.delete_host.assert_called_with(PURE_HOST_NAME)
self.assertFalse(self.array.disconnect_host.called)
mock_host.side_effect = None
self.assert_error_propagates(
[self.array.disconnect_host],
self.driver.terminate_connection, VOLUME, CONNECTOR)
def test_get_volume_stats(self): def test_get_volume_stats(self):
self.assertEqual(self.driver.get_volume_stats(), {}) self.assertEqual(self.driver.get_volume_stats(), {})

View File

@ -20,7 +20,9 @@ This driver requires Purity version 3.4.0 or later.
import cookielib import cookielib
import json import json
import re
import urllib2 import urllib2
import uuid
from oslo.config import cfg from oslo.config import cfg
@ -43,6 +45,9 @@ PURE_OPTS = [
CONF = cfg.CONF CONF = cfg.CONF
CONF.register_opts(PURE_OPTS) CONF.register_opts(PURE_OPTS)
INVALID_CHARACTERS = re.compile(r"[^-a-zA-Z0-9]")
GENERATED_NAME = re.compile(r".*-[a-f0-9]{32}-cinder$")
def _get_vol_name(volume): def _get_vol_name(volume):
"""Return the name of the volume Purity will use.""" """Return the name of the volume Purity will use."""
@ -55,6 +60,15 @@ def _get_snap_name(snapshot):
snapshot["name"]) snapshot["name"])
def _generate_purity_host_name(name):
"""Return a valid Purity host name based on the name passed in."""
if len(name) > 23:
name = name[0:23]
name = INVALID_CHARACTERS.sub("-", name)
name = name.lstrip("-")
return "{name}-{uuid}-cinder".format(name=name, uuid=uuid.uuid4().hex)
class PureISCSIDriver(san.SanISCSIDriver): class PureISCSIDriver(san.SanISCSIDriver):
"""Performs volume management on Pure Storage FlashArray.""" """Performs volume management on Pure Storage FlashArray."""
@ -205,31 +219,36 @@ class PureISCSIDriver(san.SanISCSIDriver):
def _connect(self, volume, connector): def _connect(self, volume, connector):
"""Connect the host and volume; return dict describing connection.""" """Connect the host and volume; return dict describing connection."""
host_name = self._get_host_name(connector)
vol_name = _get_vol_name(volume) vol_name = _get_vol_name(volume)
host = self._get_host(connector)
if host:
host_name = host["name"]
LOG.info(_("Re-using existing purity host {host_name!r}"
).format(host_name=host_name))
else:
host_name = _generate_purity_host_name(connector["host"])
iqn = connector["initiator"]
LOG.info(_("Creating host object {host_name!r} with IQN: {iqn}."
).format(host_name=host_name, iqn=iqn))
self._array.create_host(host_name, iqnlist=[iqn])
return self._array.connect_host(host_name, vol_name) return self._array.connect_host(host_name, vol_name)
def _get_host_name(self, connector): def _get_host(self, connector):
"""Return dictionary describing the Purity host with initiator IQN.""" """Return dict describing existing Purity host object or None."""
hosts = self._array.list_hosts() hosts = self._array.list_hosts()
for host in hosts: for host in hosts:
if connector["initiator"] in host["iqn"]: if connector["initiator"] in host["iqn"]:
return host["name"] return host
raise exception.PureDriverException( return None
reason=(_("No host object on target array with IQN: ") +
connector["initiator"]))
def terminate_connection(self, volume, connector, **kwargs): def terminate_connection(self, volume, connector, **kwargs):
"""Terminate connection.""" """Terminate connection."""
LOG.debug("Enter PureISCSIDriver.terminate_connection.") LOG.debug("Enter PureISCSIDriver.terminate_connection.")
vol_name = _get_vol_name(volume) vol_name = _get_vol_name(volume)
message = _("Disconnection failed with message: {0}") host = self._get_host(connector)
try: if host:
host_name = self._get_host_name(connector) host_name = host["name"]
except exception.PureDriverException as err:
# Happens if the host object is missing.
LOG.error(message.format(err.msg))
else:
try: try:
self._array.disconnect_host(host_name, vol_name) self._array.disconnect_host(host_name, vol_name)
except exception.PureAPIException as err: except exception.PureAPIException as err:
@ -237,7 +256,17 @@ class PureISCSIDriver(san.SanISCSIDriver):
if err.kwargs["code"] == 400: if err.kwargs["code"] == 400:
# Happens if the host and volume are not connected. # Happens if the host and volume are not connected.
ctxt.reraise = False ctxt.reraise = False
LOG.error(message.format(err.msg)) LOG.error(_("Disconnection failed with message: {msg}."
).format(msg=err.msg))
if (GENERATED_NAME.match(host_name) and not host["hgroup"] and
not self._array.list_host_connections(host_name,
private=True)):
LOG.info(_("Deleting unneeded host {host_name!r}.").format(
host_name=host_name))
self._array.delete_host(host_name)
else:
LOG.error(_("Unable to find host object in Purity with IQN: "
"{iqn}.").format(iqn=connector["initiator"]))
LOG.debug("Leave PureISCSIDriver.terminate_connection.") LOG.debug("Leave PureISCSIDriver.terminate_connection.")
def get_volume_stats(self, refresh=False): def get_volume_stats(self, refresh=False):
@ -386,6 +415,21 @@ class FlashArray(object):
"""Return a list of dictionaries describing each host.""" """Return a list of dictionaries describing each host."""
return self._http_request("GET", "host", kwargs) return self._http_request("GET", "host", kwargs)
def list_host_connections(self, host, **kwargs):
"""Return a list of dictionaries describing connected volumes."""
return self._http_request("GET", "host/{host_name}/volume".format(
host_name=host), kwargs)
def create_host(self, host, **kwargs):
"""Create a host."""
return self._http_request("POST", "host/{host_name}".format(
host_name=host), kwargs)
def delete_host(self, host):
"""Delete a host."""
return self._http_request("DELETE", "host/{host_name}".format(
host_name=host))
def connect_host(self, host, volume, **kwargs): def connect_host(self, host, volume, **kwargs):
"""Create a connection between a host and a volume.""" """Create a connection between a host and a volume."""
return self._http_request("POST", return self._http_request("POST",
@ -397,6 +441,11 @@ class FlashArray(object):
return self._http_request("DELETE", return self._http_request("DELETE",
"host/{0}/volume/{1}".format(host, volume)) "host/{0}/volume/{1}".format(host, volume))
def set_host(self, host, **kwargs):
"""Set an attribute of a host."""
return self._http_request("PUT", "host/{host_name}".format(
host_name=host), kwargs)
def list_ports(self, **kwargs): def list_ports(self, **kwargs):
"""Return a list of dictionaries describing ports.""" """Return a list of dictionaries describing ports."""
return self._http_request("GET", "port", kwargs) return self._http_request("GET", "port", kwargs)