From 1bdaeb07a5fceaffbbc10f29b549141f9dba0dfa Mon Sep 17 00:00:00 2001 From: Ronen Kat Date: Thu, 8 Aug 2013 12:12:37 +0300 Subject: [PATCH] Backup driver for IBM Tivoli Storage manager (TSM) An implementation of Cinder backup driver using TSM as a backend for Cinder backups. The driver is a wrapper for the TSM command line utility dsmc, and uses TSM image backup and restore. Re-added make_dev_path from cinder/utils.py which was removed by commit d65425453d215ad64e2bf31b66a3b613c6e7f879 Change-Id: Id105c91ffd3ca953a9fc67dc13f31c3b885bccd7 --- cinder/backup/drivers/tsm.py | 442 +++++++++++++++++++++++++++ cinder/tests/test_backup_tsm.py | 272 +++++++++++++++++ cinder/utils.py | 15 + etc/cinder/cinder.conf.sample | 15 + etc/cinder/rootwrap.d/volume.filters | 3 + 5 files changed, 747 insertions(+) create mode 100644 cinder/backup/drivers/tsm.py create mode 100644 cinder/tests/test_backup_tsm.py diff --git a/cinder/backup/drivers/tsm.py b/cinder/backup/drivers/tsm.py new file mode 100644 index 000000000..84f90f494 --- /dev/null +++ b/cinder/backup/drivers/tsm.py @@ -0,0 +1,442 @@ +# Copyright 2013 IBM Corp +# 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. + +"""Backup driver for IBM Tivoli Storage Manager (TSM). + +Implementation of a backup service that uses IBM Tivoli Storage Manager (TSM) +as the backend. The driver uses TSM command line dsmc utility to +run an image backup and restore. +This version supports backup of block devices, e.g, FC, iSCSI, local. + +A prerequisite for using the IBM TSM backup service is configuring the +Cinder host for using TSM. +""" + +import os +import stat + +from cinder.backup.driver import BackupDriver +from cinder import exception +from cinder.openstack.common import log as logging +from cinder import utils +from oslo.config import cfg + +LOG = logging.getLogger(__name__) + +tsmbackup_service_opts = [ + cfg.StrOpt('backup_tsm_volume_prefix', + default='backup', + help='Volume prefix for the backup id when backing up to TSM'), + cfg.StrOpt('backup_tsm_password', + default='password', + help='TSM password for the running username'), + cfg.BoolOpt('backup_tsm_compression', + default=True, + help='Enable or Disable compression for backups'), +] + +CONF = cfg.CONF +CONF.register_opts(tsmbackup_service_opts) + + +class TSMBackupDriver(BackupDriver): + """Provides backup, restore and delete of volumes backup for TSM.""" + + DRIVER_VERSION = '1.0.0' + + def __init__(self, context, db_driver=None): + self.context = context + self.tsm_password = CONF.backup_tsm_password + self.volume_prefix = CONF.backup_tsm_volume_prefix + super(TSMBackupDriver, self).__init__(db_driver) + + def _make_link(self, volume_path, backup_path, vol_id): + """Create a hard link for the volume block device. + + The IBM TSM client performs an image backup on a block device. + The name of the block device is the backup prefix plus the backup id + + :param volume_path: real device path name for volume + :param backup_path: path name TSM will use as volume to backup + :param vol_id: id of volume to backup (for reporting) + + :raises: InvalidBackup + """ + + try: + utils.execute('ln', volume_path, backup_path, + run_as_root=True, + check_exit_code=True) + except exception.ProcessExecutionError as e: + err = (_('backup: %(vol_id)s Failed to create device hardlink ' + 'from %(vpath)s to %(bpath)s.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': vol_id, + 'vpath': volume_path, + 'bpath': backup_path, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + + def _check_dsmc_output(self, output, check_attrs): + """Check dsmc command line utility output. + + Parse the output of the dsmc command and make sure that a given + attribute is present, and that it has the proper value. + TSM attribute has the format of "text : value". + + :param output: TSM output to parse + :param check_attrs: text to identify in the output + :returns bool -- indicate if requited output attribute found in output + """ + + parsed_attrs = {} + for line in output.split('\n'): + # parse TSM output: look for "msg : value + key, sep, val = line.partition(':') + if (sep is not None and key is not None and len(val.strip()) > 0): + parsed_attrs[key] = val.strip() + + for k, v in check_attrs.iteritems(): + if k not in parsed_attrs or parsed_attrs[k] != v: + return False + return True + + def _do_backup(self, backup_path, vol_id): + """Perform the actual backup operation. + + :param backup_path: volume path + :param vol_id: volume id + :raises: InvalidBackup + """ + + backup_attrs = {'Total number of objects backed up': '1'} + compr_flag = 'yes' if CONF.backup_tsm_compression else 'no' + + out, err = utils.execute('dsmc', + 'backup', + 'image', + '-quiet', + '-compression=%s' % compr_flag, + '-password=%s' % CONF.backup_tsm_password, + backup_path, + run_as_root=True, + check_exit_code=False) + + success = self._check_dsmc_output(out, backup_attrs) + if not success: + err = (_('backup: %(vol_id)s Failed to obtain backup ' + 'success notification from server.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': vol_id, + 'out': out, + 'err': err}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + + def _do_restore(self, restore_path, vol_id): + """Perform the actual restore operation. + + :param restore_path: volume path + :param vol_id: volume id + :raises: InvalidBackup + """ + + restore_attrs = {'Total number of objects restored': '1'} + out, err = utils.execute('dsmc', + 'restore', + 'image', + '-quiet', + '-password=%s' % self.tsm_password, + '-noprompt', + restore_path, + run_as_root=True, + check_exit_code=False) + + success = self._check_dsmc_output(out, restore_attrs) + if not success: + err = (_('restore: %(vol_id)s Failed.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': vol_id, + 'out': out, + 'err': err}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + + def _get_volume_realpath(self, volume_file, volume_id): + """Get the real path for the volume block device. + + If the volume is not a block device then issue an + InvalidBackup exsception. + + :param volume_file: file object representing the volume + :param volume_id: Volume id for backup or as restore target + :raises: InvalidBackup + :returns str -- real path of volume device + """ + + try: + # Get real path + volume_path = os.path.realpath(volume_file.name) + # Verify that path is a block device + volume_mode = os.stat(volume_path).st_mode + if not stat.S_ISBLK(volume_mode): + err = (_('backup: %(vol_id)s Failed. ' + '%(path)s is not a block device.') + % {'vol_id': volume_id, + 'path': volume_path}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + except AttributeError as e: + err = (_('backup: %(vol_id)s Failed. Cannot obtain real path ' + 'to device %(path)s.') + % {'vol_id': volume_id, + 'path': volume_file}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + except OSError as e: + err = (_('backup: %(vol_id)s Failed. ' + '%(path)s is not a file.') + % {'vol_id': volume_id, + 'path': volume_path}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + return volume_path + + def _create_device_link_using_backupid(self, + backup_id, + volume_path, + volume_id): + """Create a consistent hardlink for the volume block device. + + Create a consistent hardlink using the backup id so TSM + will be able to backup and restore to the same block device. + + :param backup_id: the backup id + :param volume_path: real path of the backup/restore device + :param volume_id: Volume id for backup or as restore target + :raises: InvalidBackup + :returns str -- hardlink path of the volume block device + """ + + hardlink_path = utils.make_dev_path('%s-%s' % + (self.volume_prefix, + backup_id)) + self._make_link(volume_path, hardlink_path, volume_id) + return hardlink_path + + def _cleanup_device_hardlink(self, + hardlink_path, + volume_path, + volume_id): + """Remove the hardlink for the volume block device. + + :param hardlink_path: hardlink to the volume block device + :param volume_path: real path of the backup/restore device + :param volume_id: Volume id for backup or as restore target + """ + + try: + utils.execute('rm', + '-f', + hardlink_path, + run_as_root=True) + except exception.ProcessExecutionError as e: + err = (_('backup: %(vol_id)s Failed to remove backup hardlink' + ' from %(vpath)s to %(bpath)s.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'vpath': volume_path, + 'bpath': hardlink_path, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + + def backup(self, backup, volume_file): + """Backup the given volume to TSM. + + TSM performs an image backup of a volume. The volume_file is + used to determine the path of the block device that TSM will + back-up. + + :param backup: backup information for volume + :param volume_file: file object representing the volume + :raises InvalidBackup + """ + + backup_id = backup['id'] + volume_id = backup['volume_id'] + volume_path = self._get_volume_realpath(volume_file, volume_id) + + LOG.debug(_('starting backup of volume: %(volume_id)s to TSM,' + ' volume path: %(volume_path)s,') + % {'volume_id': volume_id, + 'volume_path': volume_path}) + + backup_path = \ + self._create_device_link_using_backupid(backup_id, + volume_path, + volume_id) + try: + self._do_backup(backup_path, volume_id) + except exception.ProcessExecutionError as e: + err = (_('backup: %(vol_id)s Failed to run dsmc ' + 'on %(bpath)s.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'bpath': backup_path, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + except exception.Error as e: + err = (_('backup: %(vol_id)s Failed to run dsmc ' + 'due to invalid arguments ' + 'on %(bpath)s.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'bpath': backup_path, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + + finally: + self._cleanup_device_hardlink(backup_path, + volume_path, + volume_id) + + LOG.debug(_('backup %s finished.') % backup_id) + + def restore(self, backup, volume_id, volume_file): + """Restore the given volume backup from TSM server. + + :param backup: backup information for volume + :param volume_id: volume id + :param volume_file: file object representing the volume + :raises InvalidBackup + """ + + backup_id = backup['id'] + volume_path = self._get_volume_realpath(volume_file, volume_id) + + LOG.debug(_('restore: starting restore of backup from TSM' + ' to volume %(volume_id)s, ' + ' backup: %(backup_id)s') + % {'volume_id': volume_id, + 'backup_id': backup_id}) + + restore_path = \ + self._create_device_link_using_backupid(backup_id, + volume_path, + volume_id) + + try: + self._do_restore(restore_path, volume_id) + except exception.ProcessExecutionError as e: + err = (_('restore: %(vol_id)s Failed to run dsmc ' + 'on %(bpath)s.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'bpath': restore_path, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + except exception.Error as e: + err = (_('restore: %(vol_id)s Failed to run dsmc ' + 'due to invalid arguments ' + 'on %(bpath)s.\n' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'bpath': restore_path, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + + finally: + self._cleanup_device_hardlink(restore_path, + volume_path, + volume_id) + + LOG.debug(_('restore %(backup_id)s to %(volume_id)s finished.') + % {'backup_id': backup_id, + 'volume_id': volume_id}) + + def delete(self, backup): + """Delete the given backup from TSM server. + + :param backup: backup information for volume + :raises InvalidBackup + """ + + delete_attrs = {'Total number of objects deleted': '1'} + + volume_id = backup['volume_id'] + backup_id = backup['id'] + LOG.debug('delete started, backup: %s', + backup['id']) + + volume_path = utils.make_dev_path('%s-%s' % + (self.volume_prefix, backup_id)) + + try: + out, err = utils.execute('dsmc', + 'delete', + 'backup', + '-quiet', + '-noprompt', + '-objtype=image', + '-deltype=all', + '-password=%s' % self.tsm_password, + volume_path, + run_as_root=True, + check_exit_code=False) + + except exception.ProcessExecutionError as e: + err = (_('delete: %(vol_id)s Failed to run dsmc with ' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + except exception.Error as e: + err = (_('restore: %(vol_id)s Failed to run dsmc ' + 'due to invalid arguments with ' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'out': e.stdout, + 'err': e.stderr}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + + success = self._check_dsmc_output(out, delete_attrs) + if not success: + err = (_('delete: %(vol_id)s Failed with ' + 'stdout: %(out)s\n stderr: %(err)s') + % {'vol_id': volume_id, + 'out': out, + 'err': err}) + LOG.error(err) + raise exception.InvalidBackup(reason=err) + + LOG.debug(_('delete %s finished') % backup['id']) + + +def get_backup_driver(context): + return TSMBackupDriver(context) diff --git a/cinder/tests/test_backup_tsm.py b/cinder/tests/test_backup_tsm.py new file mode 100644 index 000000000..f7a74252e --- /dev/null +++ b/cinder/tests/test_backup_tsm.py @@ -0,0 +1,272 @@ +# Copyright 2013 IBM Corp +# 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. +# +""" +Tests for volume backup to IBM Tivoli Storage Manager (TSM). +""" + +import datetime +import os +import posix + +from cinder.backup.drivers import tsm +from cinder import context +from cinder import db +from cinder import exception +from cinder.openstack.common import log as logging +from cinder import test +from cinder import utils + +LOG = logging.getLogger(__name__) +SIM = None + + +class TSMBackupSimulator: + # The simulator simulates the execution of the 'dsmc' command. + # This allows the TSM backup test to succeed even if TSM is not installed. + def __init__(self): + self._backup_list = {} + self._hardlinks = [] + self._next_cmd_error = { + 'backup': '', + } + self._intro_msg = ('IBM Tivoli Storage Manager\n' + 'Command Line Backup-Archive Client Interface\n' + '...\n\n') + + def _cmd_backup(self, **kwargs): + # simulates the execution of the dsmc backup command + ret_msg = self._intro_msg + path = kwargs['path'] + + ret_msg += ('Image backup of volume \'%s\'\n\n' + 'Total number of objects inspected: 1\n' + % path) + + if self._next_cmd_error['backup'] == 'fail': + ret_msg += ('ANS1228E Sending of object \'%s\' ' + 'failed\n' % path) + ret_msg += ('ANS1063E The specified path is not a valid file ' + 'system or logical volume name.') + self._next_cmd_error['backup'] = '' + retcode = 12 + else: + ret_msg += 'Total number of objects backed up: 1' + if path not in self._backup_list: + self._backup_list[path] = [] + else: + self._backup_list[path][-1]['active'] = False + date = datetime.datetime.now() + datestr = date.strftime("%m/%d/%Y %H:%M:%S") + self._backup_list[path].append({'date': datestr, 'active': True}) + retcode = 0 + + return (ret_msg, '', retcode) + + def _backup_exists(self, path): + if path not in self._backup_list: + return ('ANS4000E Error processing \'%s\': file space does ' + 'not exist.' % path) + + return 'OK' + + def _cmd_restore(self, **kwargs): + + ret_msg = self._intro_msg + path = kwargs['path'] + exists = self._backup_exists(path) + + if exists == 'OK': + ret_msg += ('Total number of objects restored: 1\n' + 'Total number of objects failed: 0') + retcode = 0 + else: + ret_msg += exists + retcode = 12 + + return (ret_msg, '', retcode) + + def _cmd_delete(self, **kwargs): + # simulates the execution of the dsmc delete command + ret_msg = self._intro_msg + path = kwargs['path'] + exists = self._backup_exists(path) + + if exists == 'OK': + ret_msg += ('Total number of objects deleted: 1\n' + 'Total number of objects failed: 0') + retcode = 0 + for idx, backup in enumerate(self._backup_list[path]): + index = idx + del self._backup_list[path][index] + if not len(self._backup_list[path]): + del self._backup_list[path] + else: + ret_msg += exists + retcode = 12 + + return (ret_msg, '', retcode) + + def _cmd_to_dict(self, arg_list): + """Convert command for kwargs (assumes a properly formed command).""" + + ret = {'cmd': arg_list[0], + 'type': arg_list[1], + 'path': arg_list[-1]} + + for i in range(2, len(arg_list) - 1): + arg = arg_list[i].split('=') + if len(arg) == 1: + ret[arg[0]] = True + else: + ret[arg[0]] = arg[1] + + return ret + + def _exec_dsmc_cmd(self, cmd): + # simulates the execution of the dsmc command + cmd_switch = {'backup': self._cmd_backup, + 'restore': self._cmd_restore, + 'delete': self._cmd_delete} + + kwargs = self._cmd_to_dict(cmd) + if kwargs['cmd'] != 'dsmc' or kwargs['type'] not in cmd_switch: + raise exception.ProcessExecutionError(exit_code=1, + stdout='', + stderr='Not dsmc command', + cmd=' '.join(cmd)) + out, err, ret = cmd_switch[kwargs['type']](**kwargs) + return (out, err, ret) + + def exec_cmd(self, cmd): + # simulates the execution of dsmc, rm, and ln commands + if cmd[0] == 'dsmc': + out, err, ret = self._exec_dsmc_cmd(cmd) + elif cmd[0] == 'ln': + dest = cmd[2] + out = '' + if dest in self._hardlinks: + err = ('ln: failed to create hard link `%s\': ' + 'File exists' % dest) + ret = 1 + else: + self._hardlinks.append(dest) + err = '' + ret = 0 + elif cmd[0] == 'rm': + dest = cmd[2] + out = '' + if dest not in self._hardlinks: + err = ('rm: cannot remove `%s\': No such file or ' + 'directory' % dest) + ret = 1 + else: + index = self._hardlinks.index(dest) + del self._hardlinks[index] + err = '' + ret = 0 + else: + raise exception.ProcessExecutionError(exit_code=1, + stdout='', + stderr='Unsupported command', + cmd=' '.join(cmd)) + return (out, err, ret) + + def error_injection(self, cmd, error): + self._next_cmd_error[cmd] = error + + +def fake_exec(*cmd, **kwargs): + # Support only bool + check_exit_code = kwargs.pop('check_exit_code', True) + global SIM + + out, err, ret = SIM.exec_cmd(cmd) + if ret and check_exit_code: + raise exception.ProcessExecutionError( + exit_code=-1, + stdout=out, + stderr=err, + cmd=' '.join(cmd)) + return (out, err) + + +def fake_stat(path): + # Simulate stat to retun the mode of a block device + # make sure that st_mode (the first in the sequence( + # matches the mode of a block device + return posix.stat_result((25008, 5753, 5L, 1, 0, 6, 0, + 1375881199, 1375881197, 1375881197)) + + +class BackupTSMTestCase(test.TestCase): + def setUp(self): + super(BackupTSMTestCase, self).setUp() + global SIM + SIM = TSMBackupSimulator() + self.sim = SIM + self.ctxt = context.get_admin_context() + self.driver = tsm.TSMBackupDriver(self.ctxt) + self.stubs.Set(utils, 'execute', fake_exec) + self.stubs.Set(os, 'stat', fake_stat) + + def tearDown(self): + super(BackupTSMTestCase, self).tearDown() + + def _create_volume_db_entry(self, volume_id): + vol = {'id': volume_id, + 'size': 1, + 'status': 'available'} + return db.volume_create(self.ctxt, vol)['id'] + + def _create_backup_db_entry(self, backup_id): + backup = {'id': backup_id, + 'size': 1, + 'container': 'test-container', + 'volume_id': '1234-5678-1234-8888'} + return db.backup_create(self.ctxt, backup)['id'] + + def test_backup(self): + volume_id = '1234-5678-1234-8888' + self._create_volume_db_entry(volume_id) + + backup_id1 = 123 + backup_id2 = 456 + self._create_backup_db_entry(backup_id1) + self._create_backup_db_entry(backup_id2) + + volume_file = open('/dev/null', 'rw') + + # Create two backups of the volume + backup1 = db.backup_get(self.ctxt, 123) + self.driver.backup(backup1, volume_file) + backup2 = db.backup_get(self.ctxt, 456) + self.driver.backup(backup2, volume_file) + + # Create a backup that fails + self._create_backup_db_entry(666) + fail_back = db.backup_get(self.ctxt, 666) + self.sim.error_injection('backup', 'fail') + self.assertRaises(exception.InvalidBackup, + self.driver.backup, fail_back, volume_file) + + # Try to restore one, then the other + backup1 = db.backup_get(self.ctxt, 123) + self.driver.restore(backup1, volume_id, volume_file) + self.driver.restore(backup2, volume_id, volume_file) + + # Delete both backups + self.driver.delete(backup2) + self.driver.delete(backup1) diff --git a/cinder/utils.py b/cinder/utils.py index 08d1ff57a..1a18a08d7 100644 --- a/cinder/utils.py +++ b/cinder/utils.py @@ -795,6 +795,21 @@ def logging_error(message): LOG.exception(message) +def make_dev_path(dev, partition=None, base='/dev'): + """Return a path to a particular device. + + >>> make_dev_path('xvdc') + /dev/xvdc + + >>> make_dev_path('xvdc', 1) + /dev/xvdc1 + """ + path = os.path.join(base, dev) + if partition: + path += str(partition) + return path + + def total_seconds(td): """Local total_seconds implementation for compatibility with python 2.6""" if hasattr(td, 'total_seconds'): diff --git a/etc/cinder/cinder.conf.sample b/etc/cinder/cinder.conf.sample index 547e107ae..9232b03f3 100644 --- a/etc/cinder/cinder.conf.sample +++ b/etc/cinder/cinder.conf.sample @@ -451,6 +451,21 @@ #nova_api_insecure=false + +# +# Options defined in cinder.backup.services.tsm +# + +# Volume prefix for the backup id when backing up to TSM +#backup_tsm_volume_prefix=backup + +# TSM password for the running username +#backup_tsm_password=password + +# Enable or Disable compression for backups +#backup_tsm_compression=True + + # # Options defined in cinder.db.api # diff --git a/etc/cinder/rootwrap.d/volume.filters b/etc/cinder/rootwrap.d/volume.filters index 908ec0894..4e59bd09c 100644 --- a/etc/cinder/rootwrap.d/volume.filters +++ b/etc/cinder/rootwrap.d/volume.filters @@ -83,3 +83,6 @@ mkfs: CommandFilter, mkfs, root aoe-revalidate: CommandFilter, aoe-revalidate, root aoe-discover: CommandFilter, aoe-discover, root aoe-flush: CommandFilter, aoe-flush, root + +#cinder/backup/services/tsm.py +dsmc:CommandFilter,/usr/bin/dsmc,root -- 2.45.2