From 2b505bbf21d40cd946e567ede5679565721542af Mon Sep 17 00:00:00 2001 From: Patrick East Date: Thu, 19 Feb 2015 18:49:23 -0800 Subject: [PATCH] Add CHAP support to PureISCSIDriver This change adds support for iSCSI CHAP with randomly generated secrets for each initiator. Due to some restrictions with storing meta data in the backend these secrets need to be stored someplace accessible from the driver. We will use the driver specific initiator data in the Cinder DB to store them. Implements: blueprint pure-iscsi-chap-support Change-Id: I5dfc29602a41ed565af074368947c99aafdc0ab8 --- cinder/tests/test_pure.py | 121 +++++++++++++++++++++++++++------- cinder/volume/drivers/pure.py | 92 ++++++++++++++++++++++++-- 2 files changed, 185 insertions(+), 28 deletions(-) diff --git a/cinder/tests/test_pure.py b/cinder/tests/test_pure.py index 52c4732eb..949775c8c 100644 --- a/cinder/tests/test_pure.py +++ b/cinder/tests/test_pure.py @@ -114,6 +114,15 @@ SPACE_INFO_EMPTY = {"capacity": TOTAL_CAPACITY * units.Gi, "total": 0 } +CONNECTION_INFO = {"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", + }, + } + class FakePureStorageHTTPError(Exception): def __init__(self, target=None, rest_version=None, code=None, @@ -129,11 +138,12 @@ 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.mock_config = mock.Mock() + self.mock_config.san_ip = TARGET + self.mock_config.pure_api_token = API_TOKEN + self.mock_config.volume_backend_name = VOLUME_BACKEND_NAME + self.mock_config.use_chap_auth = False + self.driver = pure.PureISCSIDriver(configuration=self.mock_config) self.array = mock.Mock() self.driver._array = self.array self.purestorage_module = pure.purestorage @@ -384,18 +394,52 @@ class PureISCSIDriverTestCase(test.TestCase): 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", - }, - } + result = CONNECTION_INFO 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) + mock_connection.assert_called_with(VOLUME, CONNECTOR, None) + self.assert_error_propagates([mock_get_iscsi_port, mock_connection], + self.driver.initialize_connection, + VOLUME, CONNECTOR) + + @mock.patch(DRIVER_OBJ + "._connect") + @mock.patch(DRIVER_OBJ + "._get_target_iscsi_port") + def test_initialize_connection_with_auth(self, mock_get_iscsi_port, + mock_connection): + auth_type = "CHAP" + chap_username = CONNECTOR["host"] + chap_password = "password" + mock_get_iscsi_port.return_value = ISCSI_PORTS[0] + initiator_update = [{"key": pure.CHAP_SECRET_KEY, + "value": chap_password}] + mock_connection.return_value = { + "vol": VOLUME["name"] + "-cinder", + "lun": 1, + "auth_username": chap_username, + "auth_password": chap_password, + } + result = CONNECTION_INFO.copy() + result["data"]["auth_method"] = auth_type + result["data"]["auth_username"] = chap_username + result["data"]["auth_password"] = chap_password + + self.mock_config.use_chap_auth = True + + # Branch where no credentials were generated + real_result = self.driver.initialize_connection(VOLUME, + CONNECTOR) + mock_connection.assert_called_with(VOLUME, CONNECTOR, None) + self.assertDictMatch(result, real_result) + + # Branch where new credentials were generated + mock_connection.return_value["initiator_update"] = initiator_update + result["initiator_update"] = initiator_update + real_result = self.driver.initialize_connection(VOLUME, + CONNECTOR) + mock_connection.assert_called_with(VOLUME, CONNECTOR, None) + self.assertDictMatch(result, real_result) + self.assert_error_propagates([mock_get_iscsi_port, mock_connection], self.driver.initialize_connection, VOLUME, CONNECTOR) @@ -428,36 +472,69 @@ class PureISCSIDriverTestCase(test.TestCase): self.assert_error_propagates([mock_iscsiadm, self.array.list_ports], self.driver._choose_target_iscsi_port) + @mock.patch(DRIVER_PATH + "._generate_chap_secret", autospec=True) @mock.patch(DRIVER_OBJ + "._get_host", autospec=True) @mock.patch(DRIVER_PATH + "._generate_purity_host_name", autospec=True) - def test_connect(self, mock_generate, mock_host): + def test_connect(self, mock_generate, mock_host, mock_gen_secret): vol_name = VOLUME["name"] + "-cinder" result = {"vol": vol_name, "lun": 1} + # Branch where host already exists mock_host.return_value = PURE_HOST 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, None) self.assertEqual(result, real_result) mock_host.assert_called_with(self.driver, CONNECTOR) self.assertFalse(mock_generate.called) self.assertFalse(self.array.create_host.called) self.array.connect_host.assert_called_with(PURE_HOST_NAME, vol_name) + # 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) + real_result = self.driver._connect(VOLUME, CONNECTOR, None) 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) + self.driver._connect, VOLUME, CONNECTOR, None) + + self.mock_config.use_chap_auth = True + chap_user = CONNECTOR["host"] + chap_password = "sOmEseCr3t" + + # Branch where chap is used and credentials already exist + initiator_data = [{"key": pure.CHAP_SECRET_KEY, + "value": chap_password}] + self.driver._connect(VOLUME, CONNECTOR, initiator_data) + result["auth_username"] = chap_user + result["auth_password"] = chap_password + self.assertDictMatch(result, real_result) + self.array.set_host.assert_called_with(PURE_HOST_NAME, + host_user=chap_user, + host_password=chap_password) + + # Branch where chap is used and credentials are generated + mock_gen_secret.return_value = chap_password + self.driver._connect(VOLUME, CONNECTOR, None) + result["auth_username"] = chap_user + result["auth_password"] = chap_password + result["initiator_update"] = { + "set_values": { + pure.CHAP_SECRET_KEY: chap_password + } + } + self.assertDictMatch(result, real_result) + self.array.set_host.assert_called_with(PURE_HOST_NAME, + host_user=chap_user, + host_password=chap_password) @mock.patch(DRIVER_OBJ + "._get_host", autospec=True) def test_connect_already_connected(self, mock_host): @@ -470,7 +547,7 @@ class PureISCSIDriverTestCase(test.TestCase): code=400, text="Connection already exists" ) - actual = self.driver._connect(VOLUME, CONNECTOR) + actual = self.driver._connect(VOLUME, CONNECTOR, None) self.assertEqual(expected, actual) self.assertTrue(self.array.connect_host.called) self.assertTrue(self.array.list_volume_private_connections) @@ -485,7 +562,7 @@ class PureISCSIDriverTestCase(test.TestCase): text="Connection already exists" ) self.assertRaises(exception.PureDriverException, self.driver._connect, - VOLUME, CONNECTOR) + VOLUME, CONNECTOR, None) self.assertTrue(self.array.connect_host.called) self.assertTrue(self.array.list_volume_private_connections) @@ -500,7 +577,7 @@ class PureISCSIDriverTestCase(test.TestCase): text="Connection already exists" ) self.assertRaises(self.purestorage_module.PureHTTPError, - self.driver._connect, VOLUME, CONNECTOR) + self.driver._connect, VOLUME, CONNECTOR, None) self.assertTrue(self.array.connect_host.called) self.assertTrue(self.array.list_volume_private_connections) diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py index af87a2492..4520a8d5a 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -32,6 +32,7 @@ from cinder.i18n import _, _LE, _LI, _LW from cinder.openstack.common import log as logging from cinder import utils from cinder.volume.drivers.san import san +from cinder.volume import utils as volume_utils try: import purestorage @@ -41,7 +42,8 @@ except ImportError: LOG = logging.getLogger(__name__) PURE_OPTS = [ - cfg.StrOpt("pure_api_token", default=None, + cfg.StrOpt("pure_api_token", + default=None, help="REST API authorization token."), ] @@ -51,6 +53,8 @@ CONF.register_opts(PURE_OPTS) INVALID_CHARACTERS = re.compile(r"[^-a-zA-Z0-9]") GENERATED_NAME = re.compile(r".*-[a-f0-9]{32}-cinder$") +CHAP_SECRET_KEY = "PURE_TARGET_CHAP_SECRET" + ERR_MSG_NOT_EXIST = "does not exist" ERR_MSG_PENDING_ERADICATION = "has been destroyed" @@ -96,10 +100,14 @@ def _generate_purity_host_name(name): return "{name}-{uuid}-cinder".format(name=name, uuid=uuid.uuid4().hex) +def _generate_chap_secret(): + return volume_utils.generate_password() + + class PureISCSIDriver(san.SanISCSIDriver): """Performs volume management on Pure Storage FlashArray.""" - VERSION = "2.0.4" + VERSION = "2.0.5" SUPPORTED_REST_API_VERSIONS = ['1.2', '1.3', '1.4'] @@ -231,11 +239,17 @@ class PureISCSIDriver(san.SanISCSIDriver): " %s"), err.text) LOG.debug("Leave PureISCSIDriver.delete_snapshot.") - def initialize_connection(self, volume, connector): + def ensure_export(self, context, volume): + pass + + def create_export(self, context, volume): + pass + + def initialize_connection(self, volume, connector, initiator_data=None): """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) + connection = self._connect(volume, connector, initiator_data) properties = { "driver_volume_type": "iscsi", "data": { @@ -246,6 +260,16 @@ class PureISCSIDriver(san.SanISCSIDriver): "access_mode": "rw", }, } + + if self.configuration.use_chap_auth: + properties["data"]["auth_method"] = "CHAP" + properties["data"]["auth_username"] = connection["auth_username"] + properties["data"]["auth_password"] = connection["auth_password"] + + initiator_update = connection.get("initiator_update", False) + if initiator_update: + properties["initiator_update"] = initiator_update + LOG.debug("Leave PureISCSIDriver.initialize_connection. " "Return value: %s", str(properties)) return properties @@ -288,22 +312,70 @@ class PureISCSIDriver(san.SanISCSIDriver): raise exception.PureDriverException( reason=_("No reachable iSCSI-enabled ports on target array.")) - def _connect(self, volume, connector): + def _get_chap_credentials(self, host, data): + initiator_updates = None + username = host + password = None + if data: + for d in data: + if d["key"] == CHAP_SECRET_KEY: + password = d["value"] + break + if not password: + password = _generate_chap_secret() + initiator_updates = { + "set_values": { + CHAP_SECRET_KEY: password + } + } + return username, password, initiator_updates + + def _connect(self, volume, connector, initiator_data): """Connect the host and volume; return dict describing connection.""" connection = None + iqn = connector["initiator"] + + if self.configuration.use_chap_auth: + (chap_username, chap_password, initiator_update) = \ + self._get_chap_credentials(connector['host'], initiator_data) + vol_name = _get_vol_name(volume) host = self._get_host(connector) + if host: host_name = host["name"] LOG.info(_LI("Re-using existing purity host %(host_name)r"), {"host_name": host_name}) + if self.configuration.use_chap_auth: + if not GENERATED_NAME.match(host_name): + LOG.error(_LE("Purity host %(host_name)s is not managed " + "by Cinder and can't have CHAP credentials " + "modified. Remove IQN %(iqn)s from the host " + "to resolve this issue."), + {"host_name": host_name, + "iqn": connector["initiator"]}) + raise exception.PureDriverException( + reason=_("Unable to re-use a host that is not " + "managed by Cinder with use_chap_auth=True,")) + elif chap_username is None or chap_password is None: + LOG.error(_LE("Purity host %(host_name)s is managed by " + "Cinder but CHAP credentials could not be " + "retrieved from the Cinder database."), + {"host_name": host_name}) + raise exception.PureDriverException( + reason=_("Unable to re-use host with unknown CHAP " + "credentials configured.")) else: host_name = _generate_purity_host_name(connector["host"]) - iqn = connector["initiator"] LOG.info(_LI("Creating host object %(host_name)r with IQN:" " %(iqn)s."), {"host_name": host_name, "iqn": iqn}) self._array.create_host(host_name, iqnlist=[iqn]) + if self.configuration.use_chap_auth: + self._array.set_host(host_name, + host_user=chap_username, + host_password=chap_password) + try: connection = self._array.connect_host(host_name, vol_name) except purestorage.PureHTTPError as err: @@ -324,6 +396,14 @@ class PureISCSIDriver(san.SanISCSIDriver): if not connection: raise exception.PureDriverException( reason=_("Unable to connect or find connection to host")) + + if self.configuration.use_chap_auth: + connection["auth_username"] = chap_username + connection["auth_password"] = chap_password + + if initiator_update: + connection["initiator_update"] = initiator_update + return connection def _get_host(self, connector): -- 2.45.2