184 lines
6.6 KiB
Python
Executable File
184 lines
6.6 KiB
Python
Executable File
#!/usr/bin/python
|
|
import argparse
|
|
import configparser
|
|
import logging
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
class BackupError(Exception):
|
|
'''Raised when an error occurs backing up an item'''
|
|
|
|
|
|
class Backup(object):
|
|
|
|
RSYNC = os.environ.get('RSYNC', 'rsync')
|
|
RSYNC_DEFAULT_ARGS = ['-rtO', '--partial', '--delete']
|
|
RSYNC_EXTRA_ARGS = shlex.split(os.environ.get('RSYNC_EXTRA_ARGS', ''))
|
|
|
|
DEFAULT_CONFIG_FILENAME = 'backups.ini'
|
|
|
|
log = logging.getLogger('backup')
|
|
|
|
def __init__(self, destination, config=None, pretend=False):
|
|
self.config = configparser.ConfigParser()
|
|
if config:
|
|
self.config.read_file(config)
|
|
else:
|
|
with open(self.default_config) as config:
|
|
self.config.read_file(config)
|
|
self.config.filename = config.name
|
|
self.destination = destination
|
|
self.pretend = pretend
|
|
self.stdout = sys.stdout
|
|
self.stderr = sys.stderr
|
|
|
|
@property
|
|
def default_config(self):
|
|
try:
|
|
config_dir = os.environ['XDG_CONFIG_DIR']
|
|
except KeyError:
|
|
config_dir = os.path.expanduser('~/.config')
|
|
return os.path.join(config_dir, self.DEFAULT_CONFIG_FILENAME)
|
|
|
|
def logsetup(self, log_file=None, log_level='INFO'):
|
|
if not log_file:
|
|
return
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.NOTSET)
|
|
if log_file in ('-', '/dev/stdout', '<stdout>'):
|
|
handler = logging.StreamHandler(sys.stdout)
|
|
elif log_file in ('/dev/stderr', '<stderr>'):
|
|
handler = logging.StreamHandler(sys.stderr)
|
|
else:
|
|
handler = logging.FileHandler(log_file)
|
|
handler.setFormatter(logging.Formatter(
|
|
'%(asctime)s [%(name)s] %(levelname)s %(message)s'))
|
|
handler.setLevel(getattr(logging, log_level, logging.INFO))
|
|
logger.addHandler(handler)
|
|
self.stdout = self.stderr = handler.stream
|
|
|
|
def backup_all(self, include=None, exclude=None):
|
|
success = True
|
|
self.log.info('Starting backup')
|
|
if self.pretend:
|
|
self.log.warning('Pretend mode: no files will be copied!')
|
|
for section in self.config:
|
|
if section != self.config.default_section:
|
|
if exclude and section in exclude:
|
|
self.log.debug('Excluded section {}'.format(section))
|
|
continue
|
|
if include and section not in include:
|
|
self.log.debug('Section {} not included'.format(section))
|
|
continue
|
|
try:
|
|
self.backup(section)
|
|
except BackupError as e:
|
|
self.log.error('{}'.format(e))
|
|
success = False
|
|
except:
|
|
self.log.exception(
|
|
'Unexpected error backing up {}'.format(section))
|
|
success = False
|
|
if success:
|
|
self.log.info('Backup completed successfully')
|
|
else:
|
|
self.log.warning('Backup completed with at least one error')
|
|
return success
|
|
|
|
def backup(self, section):
|
|
log = logging.getLogger('backup.{}'.format(section))
|
|
try:
|
|
item = self.config[section]
|
|
except KeyError:
|
|
raise BackupError('Unknown backup item {}'.format(section))
|
|
|
|
try:
|
|
source = item['source']
|
|
except KeyError:
|
|
raise BackupError('Missing source for {}'.format(s.name))
|
|
try:
|
|
destination = item['destination']
|
|
except KeyError:
|
|
src_path = source.split(':', 1)[-1]
|
|
if src_path.endswith('/'):
|
|
src_path = src_path.rstrip('/')
|
|
destination = os.path.basename(src_path)
|
|
destination = os.path.join(self.destination, destination)
|
|
|
|
args = [self.RSYNC]
|
|
args.extend(self.RSYNC_DEFAULT_ARGS)
|
|
args.extend(self.RSYNC_EXTRA_ARGS)
|
|
try:
|
|
exclude = item['exclude']
|
|
except KeyError:
|
|
pass
|
|
else:
|
|
for e in shlex.split(exclude):
|
|
args.extend(('--exclude', e))
|
|
args.extend((source, destination))
|
|
if self.pretend:
|
|
args.append('-n')
|
|
log.info('Backing up {} to {}'.format(source, destination))
|
|
self._run(*args)
|
|
log.info('Backup complete')
|
|
|
|
def _run(self, *cmd):
|
|
self.log.debug('Running command: {}'.format(' '.join(cmd)))
|
|
devnull = open(os.devnull)
|
|
try:
|
|
subprocess.check_call(cmd, stdin=devnull,
|
|
stdout=self.stdout, stderr=self.stderr)
|
|
except (OSError, subprocess.CalledProcessError) as e:
|
|
raise BackupError('Error executing rsync: {}'.format(e))
|
|
except KeyboardInterrupt:
|
|
raise BackupError('rsync interrupted')
|
|
finally:
|
|
devnull.close()
|
|
|
|
|
|
def _parse_args():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--log-file', '-l', default='-', metavar='FILENAME',
|
|
help='Log file path')
|
|
parser.add_argument('--log-level', '-D', default='INFO', metavar='LEVEL',
|
|
help='Log level')
|
|
parser.add_argument('--quiet', '-q', action='store_true', default=False,
|
|
help='Do not print informational messages')
|
|
parser.add_argument('--pretend', '-p', action='store_true', default=False,
|
|
help='Execute a dry run')
|
|
parser.add_argument('--include', '-I', action='append',
|
|
help='Only back up specific items')
|
|
parser.add_argument('--exclude', '-X', action='append',
|
|
help='Do not back up specific items')
|
|
parser.add_argument('--config', '-c', type=argparse.FileType('r'),
|
|
metavar='FILENAME',
|
|
help='Path to configuration file')
|
|
parser.add_argument('destination',
|
|
help='Backup destination directory')
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = _parse_args()
|
|
backup = Backup(args.destination, args.config, args.pretend)
|
|
if args.config:
|
|
args.config.close()
|
|
if not args.quiet:
|
|
print('Backing up to {} using configuration from {}'.format(
|
|
args.destination, backup.config.filename))
|
|
backup.logsetup(args.log_file, args.log_level)
|
|
if not backup.backup_all(args.include, args.exclude):
|
|
sys.stderr.write('Errors occurred during backup\n')
|
|
if args.log_file and args.log_file != '-':
|
|
sys.stderr.write('See {} for details\n'.format(args.log_file))
|
|
raise SystemExit(1)
|
|
if not args.quiet:
|
|
print('Backup completed successfully')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|