From 992a2f4eeec08cad3a2d5e340c8f0c06833f4ac4 Mon Sep 17 00:00:00 2001 From: Anthony Lee Date: Tue, 22 Jul 2014 11:40:21 -0700 Subject: [PATCH] Add CHAP support for 3PAR ISCSI Adds CHAP support to 3PAR ISCSI. Volume metadata will store CHAP credentials to allow volumes to be attached to hosts on a 3PAR backend. Credentials are generated when the first volume is attached. Subsequent volumes lookup the CHAP credentials from an existing volume already attached to the host. Implements: blueprint add-chap-support-3par-iscsi DocImpact Change-Id: I45d77c4243339e8fc76969042634c6c45899fbae --- cinder/tests/fake_hp_3par_client.py | 2 +- cinder/tests/test_hp3par.py | 503 +++++++++++++++++- .../volume/drivers/san/hp/hp_3par_common.py | 12 +- cinder/volume/drivers/san/hp/hp_3par_iscsi.py | 189 ++++++- etc/cinder/cinder.conf.sample | 4 + 5 files changed, 693 insertions(+), 17 deletions(-) diff --git a/cinder/tests/fake_hp_3par_client.py b/cinder/tests/fake_hp_3par_client.py index 2834c0150..073eb2f00 100644 --- a/cinder/tests/fake_hp_3par_client.py +++ b/cinder/tests/fake_hp_3par_client.py @@ -21,7 +21,7 @@ import sys from cinder.tests import fake_hp_client_exceptions as hpexceptions hp3par = mock.Mock() -hp3par.version = "3.0.0" +hp3par.version = "3.1.0" hp3par.exceptions = hpexceptions sys.modules['hp3parclient'] = hp3par diff --git a/cinder/tests/test_hp3par.py b/cinder/tests/test_hp3par.py index b423522b3..2533b60c4 100644 --- a/cinder/tests/test_hp3par.py +++ b/cinder/tests/test_hp3par.py @@ -42,6 +42,9 @@ HP3PAR_SAN_SSH_PORT = 999 HP3PAR_SAN_SSH_CON_TIMEOUT = 44 HP3PAR_SAN_SSH_PRIVATE = 'foobar' +CHAP_USER_KEY = "HPQ-cinder-CHAP-name" +CHAP_PASS_KEY = "HPQ-cinder-CHAP-secret" + class HP3PARBaseDriver(object): @@ -171,6 +174,8 @@ class HP3PARBaseDriver(object): 'TASK_DONE': TASK_DONE, 'TASK_ACTIVE': TASK_ACTIVE, 'HOST_EDIT_ADD': 1, + 'CHAP_INITIATOR': 1, + 'CHAP_TARGET': 2, 'getPorts.return_value': { 'members': FAKE_FC_PORTS + [FAKE_ISCSI_PORT] } @@ -338,6 +343,7 @@ class HP3PARBaseDriver(object): configuration.hp3par_snapshot_expiration = "" configuration.hp3par_snapshot_retention = "" configuration.hp3par_iscsi_ips = [] + configuration.hp3par_iscsi_chap_enabled = False return configuration @mock.patch( @@ -1204,6 +1210,10 @@ class HP3PARBaseDriver(object): None, self.FAKE_HOST), mock.call.deleteHost(self.FAKE_HOST), + mock.call.removeVolumeMetaData( + self.VOLUME_3PAR_NAME, CHAP_USER_KEY), + mock.call.removeVolumeMetaData( + self.VOLUME_3PAR_NAME, CHAP_PASS_KEY), mock.call.logout()] mock_client.assert_has_calls(expected) @@ -2221,7 +2231,8 @@ class TestHP3PARISCSIDriver(HP3PARBaseDriver, test.TestCase): mock_client.findHost.return_value = None mock_client.getVLUN.return_value = {'lun': self.TARGET_LUN} - host = self.driver._create_host(self.volume, self.connector) + host, auth_username, auth_password = self.driver._create_host( + self.volume, self.connector) expected = [ mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), mock.call.getCPG(HP3PAR_CPG), @@ -2236,6 +2247,67 @@ class TestHP3PARISCSIDriver(HP3PARBaseDriver, test.TestCase): mock_client.assert_has_calls(expected) self.assertEqual(host['name'], self.FAKE_HOST) + self.assertEqual(auth_username, None) + self.assertEqual(auth_password, None) + + def test_create_host_chap_enabled(self): + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + mock_client.getVolume.return_value = {'userCPG': HP3PAR_CPG} + mock_client.getCPG.return_value = {} + mock_client.getHost.side_effect = [ + hpexceptions.HTTPNotFound('fake'), + {'name': self.FAKE_HOST}] + mock_client.findHost.return_value = None + mock_client.getVLUN.return_value = {'lun': self.TARGET_LUN} + + expected_mod_request = { + 'chapOperation': mock_client.HOST_EDIT_ADD, + 'chapOperationMode': mock_client.CHAP_INITIATOR, + 'chapName': 'test-user', + 'chapSecret': 'test-pass' + } + + def get_side_effect(*args): + data = {'value': None} + if args[1] == CHAP_USER_KEY: + data['value'] = 'test-user' + elif args[1] == CHAP_PASS_KEY: + data['value'] = 'test-pass' + return data + + mock_client.getVolumeMetaData.side_effect = get_side_effect + + host, auth_username, auth_password = self.driver._create_host( + self.volume, self.connector) + expected = [ + mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.getCPG(HP3PAR_CPG), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_USER_KEY), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY), + mock.call.getHost(self.FAKE_HOST), + mock.call.findHost(iqn='iqn.1993-08.org.debian:01:222'), + mock.call.createHost( + self.FAKE_HOST, + optional={'domain': None, 'persona': 1}, + iscsiNames=['iqn.1993-08.org.debian:01:222']), + mock.call.modifyHost( + 'fakehost', + expected_mod_request), + mock.call.getHost(self.FAKE_HOST) + ] + + mock_client.assert_has_calls(expected) + + self.assertEqual(host['name'], self.FAKE_HOST) + self.assertEqual(auth_username, 'test-user') + self.assertEqual(auth_password, 'test-pass') def test_create_invalid_host(self): # setup_mock_client drive with default configuration @@ -2248,7 +2320,8 @@ class TestHP3PARISCSIDriver(HP3PARBaseDriver, test.TestCase): {'name': 'fakehost.foo'}] mock_client.findHost.return_value = 'fakehost.foo' - host = self.driver._create_host(self.volume, self.connector) + host, auth_username, auth_password = self.driver._create_host( + self.volume, self.connector) expected = [ mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), @@ -2260,6 +2333,63 @@ class TestHP3PARISCSIDriver(HP3PARBaseDriver, test.TestCase): mock_client.assert_has_calls(expected) self.assertEqual(host['name'], 'fakehost.foo') + self.assertEqual(auth_username, None) + self.assertEqual(auth_password, None) + + def test_create_invalid_host_chap_enabled(self): + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + mock_client.getVolume.return_value = {'userCPG': HP3PAR_CPG} + mock_client.getCPG.return_value = {} + mock_client.getHost.side_effect = [ + hpexceptions.HTTPNotFound('Host not found.'), + {'name': 'fakehost.foo'}] + mock_client.findHost.return_value = 'fakehost.foo' + + def get_side_effect(*args): + data = {'value': None} + if args[1] == CHAP_USER_KEY: + data['value'] = 'test-user' + elif args[1] == CHAP_PASS_KEY: + data['value'] = 'test-pass' + return data + + mock_client.getVolumeMetaData.side_effect = get_side_effect + + expected_mod_request = { + 'chapOperation': mock_client.HOST_EDIT_ADD, + 'chapOperationMode': mock_client.CHAP_INITIATOR, + 'chapName': 'test-user', + 'chapSecret': 'test-pass' + } + + host, auth_username, auth_password = self.driver._create_host( + self.volume, self.connector) + + expected = [ + mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.getCPG(HP3PAR_CPG), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_USER_KEY), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY), + mock.call.getHost(self.FAKE_HOST), + mock.call.findHost(iqn='iqn.1993-08.org.debian:01:222'), + mock.call.modifyHost( + 'fakehost.foo', + expected_mod_request), + mock.call.getHost('fakehost.foo') + ] + + mock_client.assert_has_calls(expected) + + self.assertEqual(host['name'], 'fakehost.foo') + self.assertEqual(auth_username, 'test-user') + self.assertEqual(auth_password, 'test-pass') def test_create_modify_host(self): # setup_mock_client drive with default configuration @@ -2273,7 +2403,8 @@ class TestHP3PARISCSIDriver(HP3PARBaseDriver, test.TestCase): 'FCPaths': [{'wwn': '123456789012345'}, {'wwn': '123456789054321'}]}] - host = self.driver._create_host(self.volume, self.connector) + host, auth_username, auth_password = self.driver._create_host( + self.volume, self.connector) expected = [ mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), @@ -2288,6 +2419,68 @@ class TestHP3PARISCSIDriver(HP3PARBaseDriver, test.TestCase): mock_client.assert_has_calls(expected) self.assertEqual(host['name'], self.FAKE_HOST) + self.assertEqual(auth_username, None) + self.assertEqual(auth_password, None) + self.assertEqual(len(host['FCPaths']), 2) + + def test_create_modify_host_chap_enabled(self): + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + mock_client.getVolume.return_value = {'userCPG': HP3PAR_CPG} + mock_client.getCPG.return_value = {} + mock_client.getHost.side_effect = [ + {'name': self.FAKE_HOST, 'FCPaths': []}, + {'name': self.FAKE_HOST, + 'FCPaths': [{'wwn': '123456789012345'}, + {'wwn': '123456789054321'}]}] + + def get_side_effect(*args): + data = {'value': None} + if args[1] == CHAP_USER_KEY: + data['value'] = 'test-user' + elif args[1] == CHAP_PASS_KEY: + data['value'] = 'test-pass' + return data + + mock_client.getVolumeMetaData.side_effect = get_side_effect + + expected_mod_request = { + 'chapOperation': mock_client.HOST_EDIT_ADD, + 'chapOperationMode': mock_client.CHAP_INITIATOR, + 'chapName': 'test-user', + 'chapSecret': 'test-pass' + } + + host, auth_username, auth_password = self.driver._create_host( + self.volume, self.connector) + + expected = [ + mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.getCPG(HP3PAR_CPG), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_USER_KEY), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY), + mock.call.getHost(self.FAKE_HOST), + mock.call.modifyHost( + self.FAKE_HOST, + {'pathOperation': 1, + 'iSCSINames': ['iqn.1993-08.org.debian:01:222']}), + mock.call.modifyHost( + self.FAKE_HOST, + expected_mod_request + ), + mock.call.getHost(self.FAKE_HOST)] + + mock_client.assert_has_calls(expected) + + self.assertEqual(host['name'], self.FAKE_HOST) + self.assertEqual(auth_username, 'test-user') + self.assertEqual(auth_password, 'test-pass') self.assertEqual(len(host['FCPaths']), 2) def test_get_least_used_nsp_for_host_single(self): @@ -2457,6 +2650,310 @@ class TestHP3PARISCSIDriver(HP3PARBaseDriver, test.TestCase): ['1:1:1', '1:2:1']) self.assertEqual(nsp, '1:1:1') + def test_set_3par_chaps(self): + # setup_mock_client drive with default configuration + # and return the mock HTTP 3PAR client + mock_client = self.setup_driver() + + expected = [] + self.driver._set_3par_chaps( + 'test-host', 'test-vol', 'test-host', 'pass') + mock_client.assert_has_calls(expected) + + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + expected_mod_request = { + 'chapOperation': mock_client.HOST_EDIT_ADD, + 'chapOperationMode': mock_client.CHAP_INITIATOR, + 'chapName': 'test-host', + 'chapSecret': 'fake' + } + + expected = [ + mock.call.modifyHost('test-host', expected_mod_request) + ] + self.driver._set_3par_chaps( + 'test-host', 'test-vol', 'test-host', 'fake') + mock_client.assert_has_calls(expected) + + @mock.patch('cinder.volume.utils.generate_password') + def test_do_export(self, mock_utils): + # setup_mock_client drive with default configuration + # and return the mock HTTP 3PAR client + mock_client = self.setup_driver() + + volume = {'host': 'test-host@3pariscsi', + 'id': 'd03338a9-9115-48a3-8dfc-35cdfcdc15a7'} + mock_utils.return_value = 'random-pass' + mock_client.getHostVLUNs.return_value = [ + {'active': True, + 'volumeName': self.VOLUME_3PAR_NAME, + 'lun': None, 'type': 0, + 'remoteName': 'iqn.1993-08.org.debian:01:222'} + ] + mock_client.getHost.return_value = { + 'name': 'osv-0DM4qZEVSKON-DXN-NwVpw', + 'initiatorChapEnabled': True + } + mock_client.getVolumeMetaData.return_value = { + 'value': 'random-pass' + } + + expected = [] + expected_model = {'provider_auth': None} + model = self.driver._do_export(volume) + + mock_client.assert_has_calls(expected) + self.assertEqual(expected_model, model) + + mock_client.reset_mock() + + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + volume = {'host': 'test-host@3pariscsi', + 'id': 'd03338a9-9115-48a3-8dfc-35cdfcdc15a7'} + mock_utils.return_value = 'random-pass' + mock_client.getHostVLUNs.return_value = [ + {'active': True, + 'volumeName': self.VOLUME_3PAR_NAME, + 'lun': None, 'type': 0, + 'remoteName': 'iqn.1993-08.org.debian:01:222'} + ] + mock_client.getHost.return_value = { + 'name': 'osv-0DM4qZEVSKON-DXN-NwVpw', + 'initiatorChapEnabled': True + } + mock_client.getVolumeMetaData.return_value = { + 'value': 'random-pass' + } + + expected = [ + mock.call.getHostVLUNs('test-host'), + mock.call.getHost('test-host'), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_USER_KEY, 'test-host'), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY, 'random-pass') + ] + expected_model = {'provider_auth': 'CHAP test-host random-pass'} + + model = self.driver._do_export(volume) + mock_client.assert_has_calls(expected) + self.assertEqual(expected_model, model) + + @mock.patch('cinder.volume.utils.generate_password') + def test_do_export_host_not_found(self, mock_utils): + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + volume = {'host': 'test-host@3pariscsi', + 'id': 'd03338a9-9115-48a3-8dfc-35cdfcdc15a7'} + mock_utils.return_value = "random-pass" + mock_client.getHostVLUNs.side_effect = hpexceptions.HTTPNotFound( + 'fake') + + mock_client.getVolumeMetaData.return_value = { + 'value': 'random-pass' + } + + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + + expected = [ + mock.call.getHostVLUNs('test-host'), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_USER_KEY, 'test-host'), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY, 'random-pass') + ] + expected_model = {'provider_auth': 'CHAP test-host random-pass'} + + model = self.driver._do_export(volume) + mock_client.assert_has_calls(expected) + self.assertEqual(expected_model, model) + + @mock.patch('cinder.volume.utils.generate_password') + def test_do_export_host_chap_disabled(self, mock_utils): + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + volume = {'host': 'test-host@3pariscsi', + 'id': 'd03338a9-9115-48a3-8dfc-35cdfcdc15a7'} + mock_utils.return_value = 'random-pass' + mock_client.getHostVLUNs.return_value = [ + {'active': True, + 'volumeName': self.VOLUME_3PAR_NAME, + 'lun': None, 'type': 0, + 'remoteName': 'iqn.1993-08.org.debian:01:222'} + ] + mock_client.getHost.return_value = { + 'name': 'fake-host', + 'initiatorChapEnabled': False + } + mock_client.getVolumeMetaData.return_value = { + 'value': 'random-pass' + } + + mock_client.reset_mock() + + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + + expected = [ + mock.call.getHostVLUNs('test-host'), + mock.call.getHost('test-host'), + mock.call.getVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_USER_KEY, 'test-host'), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY, 'random-pass') + ] + expected_model = {'provider_auth': 'CHAP test-host random-pass'} + + model = self.driver._do_export(volume) + mock_client.assert_has_calls(expected) + self.assertEqual(expected_model, model) + + @mock.patch('cinder.volume.utils.generate_password') + def test_do_export_no_active_vluns(self, mock_utils): + # setup_mock_client drive with CHAP enabled configuration + # and return the mock HTTP 3PAR client + config = self.setup_configuration() + config.hp3par_iscsi_chap_enabled = True + mock_client = self.setup_driver(config=config) + + volume = {'host': 'test-host@3pariscsi', + 'id': 'd03338a9-9115-48a3-8dfc-35cdfcdc15a7'} + mock_utils.return_value = "random-pass" + mock_client.getHostVLUNs.return_value = [ + {'active': False, + 'volumeName': self.VOLUME_3PAR_NAME, + 'lun': None, 'type': 0, + 'remoteName': 'iqn.1993-08.org.debian:01:222'} + ] + mock_client.getHost.return_value = { + 'name': 'fake-host', + 'initiatorChapEnabled': True + } + mock_client.getVolumeMetaData.return_value = { + 'value': 'random-pass' + } + + expected = [ + mock.call.getHostVLUNs('test-host'), + mock.call.getHost('test-host'), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_USER_KEY, 'test-host'), + mock.call.setVolumeMetaData( + 'osv-0DM4qZEVSKON-DXN-NwVpw', CHAP_PASS_KEY, 'random-pass') + ] + expected_model = {'provider_auth': 'CHAP test-host random-pass'} + + model = self.driver._do_export(volume) + mock_client.assert_has_calls(expected) + self.assertEqual(model, expected_model) + + def test_ensure_export(self): + # setup_mock_client drive with default configuration + # and return the mock HTTP 3PAR client + mock_client = self.setup_driver() + + volume = {'host': 'test-host@3pariscsi', + 'id': 'd03338a9-9115-48a3-8dfc-35cdfcdc15a7'} + + mock_client.getAllVolumeMetaData.return_value = { + 'total': 0, + 'members': [] + } + + model = self.driver.ensure_export(None, volume) + + expected = [ + mock.call.login(HP3PAR_USER_NAME, HP3PAR_USER_PASS), + mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.getAllVolumeMetaData('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.logout() + ] + + expected_model = {'provider_auth': None} + + mock_client.assert_has_calls(expected) + self.assertEqual(model, expected_model) + + mock_client.getAllVolumeMetaData.return_value = { + 'total': 2, + 'members': [ + { + 'creationTimeSec': 1406074222, + 'value': 'fake-host', + 'key': CHAP_USER_KEY, + 'creationTime8601': '2014-07-22T17:10:22-07:00' + }, + { + 'creationTimeSec': 1406074222, + 'value': 'random-pass', + 'key': CHAP_PASS_KEY, + 'creationTime8601': '2014-07-22T17:10:22-07:00' + } + ] + } + + model = self.driver.ensure_export(None, volume) + + expected = [ + mock.call.login(HP3PAR_USER_NAME, HP3PAR_USER_PASS), + mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.getAllVolumeMetaData('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.logout() + ] + + expected_model = {'provider_auth': "CHAP fake-host random-pass"} + + mock_client.assert_has_calls(expected) + self.assertEqual(model, expected_model) + + def test_ensure_export_missing_volume(self): + # setup_mock_client drive with default configuration + # and return the mock HTTP 3PAR client + mock_client = self.setup_driver() + + volume = {'host': 'test-host@3pariscsi', + 'id': 'd03338a9-9115-48a3-8dfc-35cdfcdc15a7'} + + mock_client.getVolume.side_effect = hpexceptions.HTTPNotFound( + 'fake') + + model = self.driver.ensure_export(None, volume) + + expected = [ + mock.call.login(HP3PAR_USER_NAME, HP3PAR_USER_PASS), + mock.call.getVolume('osv-0DM4qZEVSKON-DXN-NwVpw'), + mock.call.logout() + ] + + expected_model = None + + mock_client.assert_has_calls(expected) + self.assertEqual(model, expected_model) + + VLUNS5_RET = ({'members': [{'portPos': {'node': 0, 'slot': 8, 'cardPort': 2}, 'active': True}, diff --git a/cinder/volume/drivers/san/hp/hp_3par_common.py b/cinder/volume/drivers/san/hp/hp_3par_common.py index 6f3931464..820c9eb27 100644 --- a/cinder/volume/drivers/san/hp/hp_3par_common.py +++ b/cinder/volume/drivers/san/hp/hp_3par_common.py @@ -66,7 +66,7 @@ from taskflow.patterns import linear_flow LOG = logging.getLogger(__name__) -MIN_CLIENT_VERSION = '3.0.0' +MIN_CLIENT_VERSION = '3.1.0' hp3par_opts = [ cfg.StrOpt('hp3par_api_url', @@ -100,7 +100,10 @@ hp3par_opts = [ help="Enable HTTP debugging to 3PAR"), cfg.ListOpt('hp3par_iscsi_ips', default=[], - help="List of target iSCSI addresses to use.") + help="List of target iSCSI addresses to use."), + cfg.BoolOpt('hp3par_iscsi_chap_enabled', + default=False, + help="Enable CHAP authentication for iSCSI connections."), ] @@ -138,10 +141,13 @@ class HP3PARCommon(object): 2.0.14 - Modified manage volume to use standard 'source-name' element. 2.0.15 - Added support for volume retype 2.0.16 - Add a better log during delete_volume time. Bug #1349636 + 2.0.17 - Added iSCSI CHAP support + This update now requires 3.1.3 MU1 firmware + and hp3parclient 3.1.0 """ - VERSION = "2.0.16" + VERSION = "2.0.17" stats = {} diff --git a/cinder/volume/drivers/san/hp/hp_3par_iscsi.py b/cinder/volume/drivers/san/hp/hp_3par_iscsi.py index e8374814d..8f08bf41e 100644 --- a/cinder/volume/drivers/san/hp/hp_3par_iscsi.py +++ b/cinder/volume/drivers/san/hp/hp_3par_iscsi.py @@ -19,7 +19,7 @@ This driver requires 3.1.3 firmware on the 3PAR array, using the 3.x version of the hp3parclient. You will need to install the python hp3parclient. -sudo pip install --upgrade "hp3parclient>=3.0" +sudo pip install --upgrade "hp3parclient>=3.1" Set the following in the cinder.conf file to enable the 3PAR iSCSI Driver along with the required flags: @@ -27,6 +27,7 @@ Set the following in the cinder.conf file to enable the volume_driver=cinder.volume.drivers.san.hp.hp_3par_iscsi.HP3PARISCSIDriver """ +import re import sys try: @@ -41,9 +42,12 @@ from cinder import utils import cinder.volume.driver from cinder.volume.drivers.san.hp import hp_3par_common as hpcommon from cinder.volume.drivers.san import san +from cinder.volume import utils as volume_utils LOG = logging.getLogger(__name__) DEFAULT_ISCSI_PORT = 3260 +CHAP_USER_KEY = "HPQ-cinder-CHAP-name" +CHAP_PASS_KEY = "HPQ-cinder-CHAP-secret" class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver): @@ -67,10 +71,12 @@ class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver): 2.0.2 - Add back-end assisted volume migrate 2.0.3 - Added support for managing/unmanaging of volumes 2.0.4 - Added support for volume retype + 2.0.5 - Added CHAP support, requires 3.1.3 MU1 firmware + and hp3parclient 3.1.0. """ - VERSION = "2.0.4" + VERSION = "2.0.5" def __init__(self, *args, **kwargs): super(HP3PARISCSIDriver, self).__init__(*args, **kwargs) @@ -263,9 +269,8 @@ class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver): """ self.common.client_login() try: - # we have to make sure we have a host - host = self._create_host(volume, connector) + host, username, password = self._create_host(volume, connector) least_used_nsp = self._get_least_used_nsp_for_host(host['name']) # now that we have a host, create the VLUN @@ -289,6 +294,12 @@ class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver): 'target_discovered': True } } + + if self.configuration.hp3par_iscsi_chap_enabled: + info['data']['auth_method'] = 'CHAP' + info['data']['auth_username'] = username + info['data']['auth_password'] = password + return info finally: self.common.client_logout() @@ -301,9 +312,31 @@ class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver): hostname = self.common._safe_hostname(connector['host']) self.common.terminate_connection(volume, hostname, iqn=connector['initiator']) + self._clear_chap_3par(volume) finally: self.common.client_logout() + def _clear_chap_3par(self, volume): + """Clears CHAP credentials on a 3par volume. + + Ignore exceptions caused by the keys not being present on a volume. + """ + vol_name = self.common._get_3par_vol_name(volume['id']) + + try: + self.common.client.removeVolumeMetaData(vol_name, CHAP_USER_KEY) + except hpexceptions.HTTPNotFound: + pass + except Exception: + raise + + try: + self.common.client.removeVolumeMetaData(vol_name, CHAP_PASS_KEY) + except hpexceptions.HTTPNotFound: + pass + except Exception: + raise + def _create_3par_iscsi_host(self, hostname, iscsi_iqn, domain, persona_id): """Create a 3PAR host. @@ -333,18 +366,37 @@ class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver): self.common.client.modifyHost(hostname, mod_request) + def _set_3par_chaps(self, hostname, volume, username, password): + """Sets a 3PAR host's CHAP credentials.""" + if not self.configuration.hp3par_iscsi_chap_enabled: + return + + mod_request = {'chapOperation': self.common.client.HOST_EDIT_ADD, + 'chapOperationMode': self.common.client.CHAP_INITIATOR, + 'chapName': username, + 'chapSecret': password} + self.common.client.modifyHost(hostname, mod_request) + def _create_host(self, volume, connector): """Creates or modifies existing 3PAR host.""" # make sure we don't have the host already host = None + username = None + password = None hostname = self.common._safe_hostname(connector['host']) cpg = self.common.get_cpg(volume, allowSnap=True) domain = self.common.get_domain(cpg) + + # Get the CHAP secret if CHAP is enabled + if self.configuration.hp3par_iscsi_chap_enabled: + vol_name = self.common._get_3par_vol_name(volume['id']) + username = self.common.client.getVolumeMetaData( + vol_name, CHAP_USER_KEY)['value'] + password = self.common.client.getVolumeMetaData( + vol_name, CHAP_PASS_KEY)['value'] + try: host = self.common._get_3par_host(hostname) - if 'iSCSIPaths' not in host or len(host['iSCSIPaths']) < 1: - self._modify_3par_iscsi_host(hostname, connector['initiator']) - host = self.common._get_3par_host(hostname) except hpexceptions.HTTPNotFound: # get persona from the volume type extra specs persona_id = self.common.get_persona_type(volume) @@ -353,17 +405,134 @@ class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver): connector['initiator'], domain, persona_id) + self._set_3par_chaps(hostname, volume, username, password) host = self.common._get_3par_host(hostname) + else: + if 'iSCSIPaths' not in host or len(host['iSCSIPaths']) < 1: + self._modify_3par_iscsi_host(hostname, connector['initiator']) + self._set_3par_chaps(hostname, volume, username, password) + host = self.common._get_3par_host(hostname) + elif (not host['initiatorChapEnabled'] and + self.configuration.hp3par_iscsi_chap_enabled): + LOG.warn(_("Host exists without CHAP credentials set and has " + "iSCSI attachments but CHAP is enabled. Updating " + "host with new CHAP credentials.")) + self._set_3par_chaps(hostname, volume, username, password) + + return host, username, password - return host + def _do_export(self, volume): + """Gets the associated account, generates CHAP info and updates.""" + model_update = {} + + if not self.configuration.hp3par_iscsi_chap_enabled: + model_update['provider_auth'] = None + return model_update + + # CHAP username will be the hostname + chap_username = volume['host'].split('@')[0] + + chap_password = None + try: + # Get all active VLUNs for the host + vluns = self.common.client.getHostVLUNs(chap_username) + + # Host has active VLUNs... is CHAP enabled on host? + host_info = self.common.client.getHost(chap_username) + + if not host_info['initiatorChapEnabled']: + LOG.warn(_("Host has no CHAP key, but CHAP is enabled.")) + + except hpexceptions.HTTPNotFound: + chap_password = volume_utils.generate_password(16) + LOG.warn(_("No host or VLUNs exist. Generating new CHAP key.")) + else: + # Get a list of all iSCSI VLUNs and see if there is already a CHAP + # key assigned to one of them. Use that CHAP key if present, + # otherwise create a new one. Skip any VLUNs that are missing + # CHAP credentials in metadata. + chap_exists = False + active_vluns = 0 + + for vlun in vluns: + if not vlun['active']: + continue + + active_vluns += 1 + + # iSCSI connections start with 'iqn'. + if ('remoteName' in vlun and + re.match('iqn.*', vlun['remoteName'])): + try: + chap_password = self.common.client.getVolumeMetaData( + vlun['volumeName'], CHAP_PASS_KEY)['value'] + chap_exists = True + break + except hpexceptions.HTTPNotFound: + LOG.debug("The VLUN %s is missing CHAP credentials " + "but CHAP is enabled. Skipping." % + vlun['remoteName']) + else: + LOG.warn(_("Non-iSCSI VLUN detected.")) + + if not chap_exists: + chap_password = volume_utils.generate_password(16) + LOG.warn(_("No VLUN contained CHAP credentials. " + "Generating new CHAP key.")) + + # Add CHAP credentials to the volume metadata + vol_name = self.common._get_3par_vol_name(volume['id']) + self.common.client.setVolumeMetaData( + vol_name, CHAP_USER_KEY, chap_username) + self.common.client.setVolumeMetaData( + vol_name, CHAP_PASS_KEY, chap_password) + + model_update['provider_auth'] = ('CHAP %s %s' % + (chap_username, chap_password)) + + return model_update @utils.synchronized('3par', external=True) def create_export(self, context, volume): - pass + try: + self.common.client_login() + return self._do_export(volume) + finally: + self.common.client_logout() @utils.synchronized('3par', external=True) def ensure_export(self, context, volume): - pass + """Ensure the volume still exists on the 3PAR. + + Also retrieves CHAP credentials, if present on the volume + """ + try: + self.common.client_login() + vol_name = self.common._get_3par_vol_name(volume['id']) + self.common.client.getVolume(vol_name) + except hpexceptions.HTTPNotFound: + LOG.error(_("Volume %s doesn't exist on array.") % vol_name) + else: + metadata = self.common.client.getAllVolumeMetaData(vol_name) + + username = None + password = None + model_update = {} + model_update['provider_auth'] = None + + for member in metadata['members']: + if member['key'] == CHAP_USER_KEY: + username = member['value'] + elif member['key'] == CHAP_PASS_KEY: + password = member['value'] + + if username and password: + model_update['provider_auth'] = ('CHAP %s %s' % + (username, password)) + + return model_update + finally: + self.common.client_logout() @utils.synchronized('3par', external=True) def remove_export(self, context, volume): diff --git a/etc/cinder/cinder.conf.sample b/etc/cinder/cinder.conf.sample index 38af8e7cb..2a13d31c7 100644 --- a/etc/cinder/cinder.conf.sample +++ b/etc/cinder/cinder.conf.sample @@ -1693,6 +1693,10 @@ # List of target iSCSI addresses to use. (list value) #hp3par_iscsi_ips= +# Enable CHAP authentication for iSCSI connections. (boolean +# value) +#hp3par_iscsi_chap_enabled=false + # # Options defined in cinder.volume.drivers.san.hp.hp_lefthand_rest_proxy -- 2.45.2