diff --git a/cinder/tests/targets/test_iet_driver.py b/cinder/tests/targets/test_iet_driver.py new file mode 100644 index 00000000000..762cca472a4 --- /dev/null +++ b/cinder/tests/targets/test_iet_driver.py @@ -0,0 +1,283 @@ +# 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 contextlib +import os +import shutil +import StringIO +import tempfile + +import mock +from oslo_concurrency import processutils as putils +from oslo_utils import timeutils + +from cinder import context +from cinder import exception +from cinder.openstack.common import fileutils +from cinder import test +from cinder import utils +from cinder.volume import configuration as conf +from cinder.volume.targets import iet + + +class TestIetAdmDriver(test.TestCase): + + def __init__(self, *args, **kwargs): + super(TestIetAdmDriver, self).__init__(*args, **kwargs) + self.configuration = conf.Configuration(None) + self.configuration.append_config_values = mock.Mock(return_value=0) + self.configuration.iscsi_ip_address = '10.9.8.7' + self.fake_project_id = 'ed2c1fd4-5fc0-11e4-aa15-123b93f75cba' + self.fake_volume_id = '83c2e877-feed-46be-8435-77884fe55b45' + self.target = iet.IetAdm(root_helper=utils.get_root_helper(), + configuration=self.configuration) + self.testvol =\ + {'project_id': self.fake_project_id, + 'name': 'testvol', + 'size': 1, + 'id': self.fake_volume_id, + 'volume_type_id': None, + 'provider_location': '10.9.8.7:3260 ' + 'iqn.2010-10.org.openstack:' + 'volume-%s 0' % self.fake_volume_id, + 'provider_auth': 'CHAP stack-1-a60e2611875f40199931f2' + 'c76370d66b 2FE0CQ8J196R', + 'provider_geometry': '512 512', + 'created_at': timeutils.utcnow(), + 'host': 'fake_host@lvm#lvm'} + + self.expected_iscsi_properties = \ + {'auth_method': 'CHAP', + 'auth_password': '2FE0CQ8J196R', + 'auth_username': 'stack-1-a60e2611875f40199931f2c76370d66b', + 'encrypted': False, + 'logical_block_size': '512', + 'physical_block_size': '512', + 'target_discovered': False, + 'target_iqn': 'iqn.2010-10.org.openstack:volume-%s' % + self.fake_volume_id, + 'target_lun': 0, + 'target_portal': '10.10.7.1:3260', + 'volume_id': self.fake_volume_id} + + def setUp(self): + super(TestIetAdmDriver, self).setUp() + self.fake_volumes_dir = tempfile.mkdtemp() + fileutils.ensure_tree(self.fake_volumes_dir) + self.addCleanup(self._cleanup) + + self.exec_patcher = mock.patch.object(utils, 'execute') + self.mock_execute = self.exec_patcher.start() + self.addCleanup(self.exec_patcher.stop) + + def _cleanup(self): + if os.path.exists(self.fake_volumes_dir): + shutil.rmtree(self.fake_volumes_dir) + + def test_get_target(self): + tmp_file = StringIO.StringIO() + tmp_file.write( + 'tid:1 name:iqn.2010-10.org.openstack:volume-83c2e877-feed-46be-8435-77884fe55b45\n' # noqa + ' sid:844427031282176 initiator:iqn.1994-05.com.redhat:5a6894679665\n' # noqa + ' cid:0 ip:10.9.8.7 state:active hd:none dd:none') + tmp_file.seek(0) + with mock.patch('__builtin__.open') as mock_open: + mock_open.return_value = contextlib.closing(tmp_file) + self.assertEqual('1', + self.target._get_target( + 'iqn.2010-10.org.openstack:volume-83c2e877-feed-46be-8435-77884fe55b45' # noqa + )) + + # Test the failure case: Failed to handle the config file + mock_open.side_effect = StandardError() + self.assertRaises(StandardError, + self.target._get_target, + '') + + @mock.patch('os.path.exists', return_value=True) + @mock.patch('cinder.utils.temporary_chown') + def test_get_target_chap_auth(self, mock_chown, mock_exists): + tmp_file = StringIO.StringIO() + tmp_file.write( + 'Target iqn.2010-10.org.openstack:volume-83c2e877-feed-46be-8435-77884fe55b45\n' # noqa + ' IncomingUser otzLy2UYbYfnP4zXLG5z 234Zweo38VGBBvrpK9nt\n' + ' Lun 0 Path=/dev/stack-volumes-lvmdriver-1/volume-83c2e877-feed-46be-8435-77884fe55b45,Type=fileio\n' # noqa + ) + tmp_file.seek(0) + test_vol = ('iqn.2010-10.org.openstack:' + 'volume-83c2e877-feed-46be-8435-77884fe55b45') + expected = ('otzLy2UYbYfnP4zXLG5z', '234Zweo38VGBBvrpK9nt') + with mock.patch('__builtin__.open') as mock_open: + ictx = context.get_admin_context() + mock_open.return_value = contextlib.closing(tmp_file) + self.assertEqual(expected, + self.target._get_target_chap_auth(ictx, test_vol)) + self.assertTrue(mock_open.called) + + # Test the failure case: Failed to handle the config file + mock_open.side_effect = StandardError() + self.assertRaises(StandardError, + self.target._get_target_chap_auth, + ictx, + test_vol) + + @mock.patch('cinder.volume.targets.iet.IetAdm._get_target', + return_value=0) + @mock.patch('cinder.utils.execute') + @mock.patch('os.path.exists', return_value=True) + @mock.patch('cinder.utils.temporary_chown') + def test_create_iscsi_target(self, mock_chown, mock_exists, + mock_execute, mock_get_targ): + mock_execute.return_value = ('', '') + tmp_file = StringIO.StringIO() + test_vol = ('iqn.2010-10.org.openstack:' + 'volume-83c2e877-feed-46be-8435-77884fe55b45') + with mock.patch('__builtin__.open') as mock_open: + mock_open.return_value = contextlib.closing(tmp_file) + self.assertEqual( + 0, + self.target.create_iscsi_target( + test_vol, + 0, + 0, + self.fake_volumes_dir)) + self.assertTrue(mock_execute.called) + self.assertTrue(mock_open.called) + self.assertTrue(mock_get_targ.called) + + # Test the failure case: Failed to chown the config file + mock_open.side_effect = putils.ProcessExecutionError + self.assertRaises(exception.ISCSITargetCreateFailed, + self.target.create_iscsi_target, + test_vol, + 0, + 0, + self.fake_volumes_dir) + + # Test the failure case: Failed to set new auth + mock_execute.side_effect = putils.ProcessExecutionError + self.assertRaises(exception.ISCSITargetCreateFailed, + self.target.create_iscsi_target, + test_vol, + 0, + 0, + self.fake_volumes_dir) + + @mock.patch('cinder.utils.execute') + @mock.patch('os.path.exists', return_value=True) + def test_update_config_file_failure(self, mock_exists, mock_execute): + test_vol = ('iqn.2010-10.org.openstack:' + 'volume-83c2e877-feed-46be-8435-77884fe55b45') + + # Test the failure case: conf file does not exist + mock_exists.return_value = False + mock_execute.side_effect = putils.ProcessExecutionError + self.assertRaises(exception.ISCSITargetCreateFailed, + self.target.update_config_file, + test_vol, + 0, + self.fake_volumes_dir, + "foo bar") + + @mock.patch('cinder.volume.targets.iet.IetAdm._get_target', + return_value=1) + @mock.patch('cinder.utils.execute') + def test_create_iscsi_target_already_exists(self, mock_execute, + mock_get_targ): + mock_execute.return_value = ('fake out', 'fake err') + test_vol = 'iqn.2010-10.org.openstack:'\ + 'volume-83c2e877-feed-46be-8435-77884fe55b45' + self.assertEqual( + 1, + self.target.create_iscsi_target( + test_vol, + 1, + 0, + self.fake_volumes_dir)) + self.assertTrue(mock_get_targ.called) + self.assertTrue(mock_execute.called) + + @mock.patch('cinder.volume.targets.iet.IetAdm._find_sid_cid_for_target', + return_value=None) + @mock.patch('os.path.exists', return_value=False) + @mock.patch.object(utils, 'execute') + def test_remove_iscsi_target(self, mock_execute, mock_exists, mock_find): + + # Test the normal case + self.target.remove_iscsi_target(1, + 0, + self.testvol['id'], + self.testvol['name']) + mock_execute.assert_any_calls('ietadm', + '--op', + 'delete', + '--tid=1', + run_as_root=True) + + # Test the failure case: putils.ProcessExecutionError + mock_execute.side_effect = putils.ProcessExecutionError + self.assertRaises(exception.ISCSITargetRemoveFailed, + self.target.remove_iscsi_target, + 1, + 0, + self.testvol['id'], + self.testvol['name']) + + def test_find_sid_cid_for_target(self): + tmp_file = StringIO.StringIO() + tmp_file.write( + 'tid:1 name:iqn.2010-10.org.openstack:volume-83c2e877-feed-46be-8435-77884fe55b45\n' # noqa + ' sid:844427031282176 initiator:iqn.1994-05.com.redhat:5a6894679665\n' # noqa + ' cid:0 ip:10.9.8.7 state:active hd:none dd:none') + tmp_file.seek(0) + with mock.patch('__builtin__.open') as mock_open: + mock_open.return_value = contextlib.closing(tmp_file) + self.assertEqual(('844427031282176', '0'), + self.target._find_sid_cid_for_target( + '1', + 'iqn.2010-10.org.openstack:volume-83c2e877-feed-46be-8435-77884fe55b45', # noqa + 'volume-83c2e877-feed-46be-8435-77884fe55b45' # noqa + )) + + @mock.patch('cinder.volume.targets.iet.IetAdm._get_target', + return_value=1) + @mock.patch('cinder.utils.execute') + @mock.patch.object(iet.IetAdm, '_get_target_chap_auth') + def test_create_export(self, mock_get_chap, mock_execute, + mock_get_targ): + mock_execute.return_value = ('', '') + mock_get_chap.return_value = ('QZJbisGmn9AL954FNF4D', + 'P68eE7u9eFqDGexd28DQ') + expected_result = {'location': '10.9.8.7:3260,1 ' + 'iqn.2010-10.org.openstack:testvol 0', + 'auth': 'CHAP ' + 'QZJbisGmn9AL954FNF4D P68eE7u9eFqDGexd28DQ'} + ctxt = context.get_admin_context() + self.assertEqual(expected_result, + self.target.create_export(ctxt, + self.testvol, + self.fake_volumes_dir)) + self.assertTrue(mock_execute.called) + + @mock.patch('cinder.volume.targets.iet.IetAdm._get_target', + return_value=1) + def test_ensure_export(self, mock_get_target): + ctxt = context.get_admin_context() + with mock.patch.object(self.target, 'create_iscsi_target'): + self.target.ensure_export(ctxt, + self.testvol, + self.fake_volumes_dir) + self.target.create_iscsi_target.assert_called_once_with( + 'iqn.2010-10.org.openstack:testvol', + 1, 0, self.fake_volumes_dir, None, + check_exit_code=False, + old_name=None) diff --git a/cinder/volume/targets/iet.py b/cinder/volume/targets/iet.py index 3e3a2ff80cf..9af7f0283f1 100644 --- a/cinder/volume/targets/iet.py +++ b/cinder/volume/targets/iet.py @@ -10,24 +10,307 @@ # License for the specific language governing permissions and limitations # under the License. +import os +import re +import stat -class IetAdm(object): +from oslo_concurrency import processutils as putils +from oslo_log import log as logging + +from cinder import exception +from cinder.i18n import _LI, _LE, _LW +from cinder import utils +from cinder.volume.targets import iscsi + +LOG = logging.getLogger(__name__) + + +class IetAdm(iscsi.ISCSITarget): VERSION = '0.1' def __init__(self, *args, **kwargs): super(IetAdm, self).__init__(*args, **kwargs) + self.iet_conf = self.configuration.safe_get('iet_conf') + self.iscsi_iotype = self.configuration.safe_get('iscsi_iotype') + self.auth_type = 'IncomingUser' + self.iet_sessions = '/proc/net/iet/session' - def _get_target_chap_auth(self, name): + def _get_target(self, iqn): + + # Find existing iSCSI target session from /proc/net/iet/session + # + # tid:2 name:iqn.2010-10.org:volume-222 + # sid:562950561399296 initiator:iqn.1994-05.com:5a6894679665 + # cid:0 ip:192.168.122.1 state:active hd:none dd:none + # tid:1 name:iqn.2010-10.org:volume-111 + # sid:281475567911424 initiator:iqn.1994-05.com:5a6894679665 + # cid:0 ip:192.168.122.1 state:active hd:none dd:none + + iscsi_target = 0 + try: + with open(self.iet_sessions, 'r') as f: + sessions = f.read() + except Exception: + LOG.exception(_LE("Failed to open iet session list for %s"), iqn) + raise + + session_list = re.split('^tid:(?m)', sessions)[1:] + for ses in session_list: + m = re.match('(\d+) name:(\S+)\s+', ses) + if m and iqn in m.group(2): + return m.group(1) + + return iscsi_target + + def _get_iscsi_target(self, context, vol_id): pass - def ensure_export(self, context, volume, volume_path): - pass + def _get_target_and_lun(self, context, volume): - def create_export(self, context, volume, volume_path): - pass + # For ietadm dev starts at lun 0 + lun = 0 - def remove_export(self, context, volume): - pass + # Using 0, ietadm tries to search empty tid for creating + # new iSCSI target + iscsi_target = 0 - def initialize_connection(self, volume, connector): - pass + # Find existing iSCSI target based on iqn + iqn = '%svolume-%s' % (self.iscsi_target_prefix, volume['id']) + iscsi_target = self._get_target(iqn) + + return iscsi_target, lun + + def _get_target_chap_auth(self, context, name): + + vol_id = name.split(':')[1] + if os.path.exists(self.iet_conf): + try: + with utils.temporary_chown(self.iet_conf): + with open(self.iet_conf, 'r') as f: + iet_conf_text = f.readlines() + except Exception: + # If we fail to handle config file, raise exception here to + # prevent unexpected behavior during subsequent operations. + LOG.exception(_LE("Failed to open config for %s."), vol_id) + raise + + target_found = False + for line in iet_conf_text: + if target_found: + m = re.search('(\w+) (\w+) (\w+)', line) + if m: + return (m.group(2), m.group(3)) + else: + LOG.debug("Failed to find CHAP auth from config " + "for %s", vol_id) + return None + elif name in line: + target_found = True + else: + # Missing config file is unxepected sisuation. But we will create + # new config file during create_iscsi_target(). Just we warn the + # operator here. + LOG.warn(_LW("Failed to find CHAP auth from config for " + "%(vol_id)s. Config file %(conf)s does not exist."), + {'vol_id': vol_id, 'conf': self.iet_conf}) + return None + + def create_iscsi_target(self, name, tid, lun, path, + chap_auth=None, **kwargs): + + config_auth = None + vol_id = name.split(':')[1] + + # Check the target is already existing. + tmp_tid = self._get_target(name) + + # Create a new iSCSI target. If a target already exists, + # the command returns 234, but we ignore it. + try: + self._new_target(name, tid) + tid = self._get_target(name) + self._new_logicalunit(tid, lun, path) + + if chap_auth is not None: + (username, password) = chap_auth + config_auth = ' '.join((self.auth_type,) + chap_auth) + self._new_auth(tid, self.auth_type, username, password) + except putils.ProcessExecutionError: + LOG.exception(_LE("Failed to create iscsi target for volume " + "id:%s"), vol_id) + raise exception.ISCSITargetCreateFailed(volume_id=vol_id) + + # Update config file only if new scsi target is created. + if not tmp_tid: + self.update_config_file(name, tid, path, config_auth) + + return tid + + def update_config_file(self, name, tid, path, config_auth): + + conf_file = self.iet_conf + vol_id = name.split(':')[1] + + # If config file does not exist, create a blank conf file and + # add configuration for the volume on the new file. + if not os.path.exists(conf_file): + try: + utils.execute("truncate", conf_file, "--size=0", + run_as_root=True) + except putils.ProcessExecutionError: + LOG.exception(_LE("Failed to create %(conf)s for volume " + "id:%(vol_id)s"), + {'conf': conf_file, 'vol_id': vol_id}) + raise exception.ISCSITargetCreateFailed(volume_id=vol_id) + + try: + volume_conf = """ + Target %s + %s + Lun 0 Path=%s,Type=%s + """ % (name, config_auth, path, self._iotype(path)) + + with utils.temporary_chown(conf_file): + with open(conf_file, 'a+') as f: + f.write(volume_conf) + except Exception: + LOG.exception(_LE("Failed to update %(conf)s for volume " + "id:%(vol_id)s"), + {'conf': conf_file, 'vol_id': vol_id}) + raise exception.ISCSITargetCreateFailed(volume_id=vol_id) + + def remove_iscsi_target(self, tid, lun, vol_id, vol_name, **kwargs): + LOG.info(_LI("Removing iscsi_target for volume: %s"), vol_id) + + try: + self._delete_logicalunit(tid, lun) + session_info = self._find_sid_cid_for_target(tid, vol_name, vol_id) + if session_info: + sid, cid = session_info + self._force_delete_target(tid, sid, cid) + + self._delete_target(tid) + except putils.ProcessExecutionError: + LOG.exception(_LE("Failed to remove iscsi target for volume " + "id:%s"), vol_id) + raise exception.ISCSITargetRemoveFailed(volume_id=vol_id) + + vol_uuid_file = vol_name + conf_file = self.iet_conf + if os.path.exists(conf_file): + try: + with utils.temporary_chown(conf_file): + with open(conf_file, 'r+') as iet_conf_text: + full_txt = iet_conf_text.readlines() + new_iet_conf_txt = [] + count = 0 + for line in full_txt: + if count > 0: + count -= 1 + continue + elif vol_uuid_file in line: + count = 2 + continue + else: + new_iet_conf_txt.append(line) + + iet_conf_text.seek(0) + iet_conf_text.truncate(0) + iet_conf_text.writelines(new_iet_conf_txt) + except Exception: + LOG.exception(_LE("Failed to update %(conf)s for volume id " + "%(vol_id) after removing iscsi target"), + {'conf': conf_file, 'vol_id': vol_id}) + raise exception.ISCSITargetRemoveFailed(volume_id=vol_id) + else: + LOG.warn(_LW("Failed to update %(conf)s for volume id %(vol_id) " + "after removing iscsi target. " + "%(conf)s does not exist."), + {'conf': conf_file, 'vol_id': vol_id}) + + def _find_sid_cid_for_target(self, tid, name, vol_id): + """Find sid, cid for existing iscsi target""" + + try: + with open(self.iet_sessions, 'r') as f: + sessions = f.read() + except Exception as e: + LOG.info(_LI("Failed to open iet session list for " + "%(vol_id)s: %(e)s"), + {'vol_id': vol_id, 'e': e}) + return None + + session_list = re.split('^tid:(?m)', sessions)[1:] + for ses in session_list: + m = re.match('(\d+) name:(\S+)\s+sid:(\d+).+\s+cid:(\d+)', ses) + if m and tid in m.group(1) and name in m.group(2): + return m.group(3), m.group(4) + + def _is_block(self, path): + mode = os.stat(path).st_mode + return stat.S_ISBLK(mode) + + def _iotype(self, path): + if self.iscsi_iotype == 'auto': + return 'blockio' if self._is_block(path) else 'fileio' + else: + return self.iscsi_iotype + + def _new_target(self, name, tid): + """Create new scsi target using specified parameters. + + If the target already exists, ietadm returns + 'Invalid argument' and error code '234'. + This should be ignored for ensure export case. + """ + utils.execute('ietadm', '--op', 'new', + '--tid=%s' % tid, + '--params', 'Name=%s' % name, + run_as_root=True, check_exit_code=[0, 234]) + + def _delete_target(self, tid): + utils.execute('ietadm', '--op', 'delete', + '--tid=%s' % tid, + run_as_root=True) + + def _force_delete_target(self, tid, sid, cid): + utils.execute('ietadm', '--op', 'delete', + '--tid=%s' % tid, + '--sid=%s' % sid, + '--cid=%s' % cid, + run_as_root=True) + + def show_target(self, tid, iqn=None): + utils.execute('ietadm', '--op', 'show', + '--tid=%s' % tid, + run_as_root=True) + + def _new_logicalunit(self, tid, lun, path): + """Attach a new volume to scsi target as a logical unit. + + If a logical unit exists on the specified target lun, + ietadm returns 'File exists' and error code '239'. + This should be ignored for ensure export case. + """ + + utils.execute('ietadm', '--op', 'new', + '--tid=%s' % tid, + '--lun=%d' % lun, + '--params', + 'Path=%s,Type=%s' % (path, self._iotype(path)), + run_as_root=True, check_exit_code=[0, 239]) + + def _delete_logicalunit(self, tid, lun): + utils.execute('ietadm', '--op', 'delete', + '--tid=%s' % tid, + '--lun=%d' % lun, + run_as_root=True) + + def _new_auth(self, tid, type, username, password): + utils.execute('ietadm', '--op', 'new', + '--tid=%s' % tid, + '--user', + '--params=%s=%s,Password=%s' % (type, + username, + password), + run_as_root=True)