""" BSD 2-Clause License restic subprocess wrapper Copyright (c) 2018, Max Resnick All rights reserved. restic-py is simple wrapper to restic binary for backups and monitoring """ import os import configparser import argparse import logging import sys import time import socket import getpass import subprocess import pathlib import collections import shlex # logging LOGLEVELS = { 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, 'WARNING': logging.WARNING, 'ERROR': logging.ERROR } HOME_DIR = os.path.expanduser('~') DEFAULTZ = { 'namespace': f'{getpass.getuser()}-{socket.gethostname()}', 'exclude_file': pathlib.Path(f'{HOME_DIR}/.local/etc/restic-wrapper/exclude.conf'), 'password_file': pathlib.Path(f'{HOME_DIR}/.ssh/resticpass'), } ResticCmdConfig = collections.namedtuple( 'ResticCmdConfig', ['exec_bin', 'base_args', 'exclude'] ) # Log Config log_args = { 'filename': f'{HOME_DIR}/.local/logs/restic.log', 'format': '%(asctime)s %(levelname)s %(message)s', #'datefmt': '%m/%d/%Y %I:%M:%S %p' } if sys.stdin.isatty(): log_file = sys.stdout log_args = {'stream': sys.stdout} log_args['level'] = LOGLEVELS[os.getenv('RESTIC_LOGLEVEL', 'INFO')] logging.basicConfig(**log_args) log = logging.getLogger(__name__) # End Log Config def prepare_args(): """prepare arg parser""" parser = argparse.ArgumentParser(description='wrapper to restic') parser.add_argument('-c', '--config', default=f'{HOME_DIR}/.local/etc/restic-wrapper/restic.ini', type=pathlib.Path) parser.add_argument('-e', '--exclude-file', type=pathlib.Path) parser.add_argument('-n', '--namespace') parser.add_argument('-u', '--restic-uri') parser.add_argument('-k', '--password-file') subparsers = parser.add_subparsers(title='wrapper commands', description='valid subcommands', required=True) backup_parser = subparsers.add_parser('backup') backup_parser.set_defaults(func=run_backup) backup_parser.add_argument('path', nargs='?', default=pathlib.Path('.'), help='path to backup', type=pathlib.Path) prune_parser = subparsers.add_parser('prune') prune_parser.set_defaults(func=run_prune) check_parser = subparsers.add_parser('check') check_parser.set_defaults(func=run_check) watch_parser = subparsers.add_parser('watch') watch_parser.set_defaults(func=run_watch) pre_operation_check_parser = subparsers.add_parser('validate-config') pre_operation_check_parser.set_defaults(func=run_pre_operation_check) args = parser.parse_args() # load ini file cfg = configparser.ConfigParser() cfg.read(args.config) # build a dictionary of from restic ini section config_file_args = dict(cfg.items('restic')) # build dictionary of the args command_line_args = {k: v for k, v in vars(args).items() if v is not None} combined = collections.ChainMap(command_line_args, os.environ, config_file_args, DEFAULTZ) return combined # subprocess utils def run(cmd, *cmd_args, stdin=None, cstdout=False, cstderr=False, cwd=None): """runs a given command""" runcmd = [cmd, *cmd_args] runcmd = [str(s) for s in runcmd] runcmd = shlex.join(runcmd) stderr_pipe = subprocess.PIPE if cstderr else None stdout_pipe = subprocess.PIPE if cstdout else None _r = subprocess.run(shlex.split(runcmd), stdout=stdout_pipe, stderr=stderr_pipe, input=stdin, cwd=cwd, check=False) stdout = _r.stdout stderr = _r.stderr if not stdout: stdout = b'' if not stderr: stderr = b'' exit_clean = _r.returncode == 0 return exit_clean, stdout, stderr # end suprocess utils def resolve_restic(): """resolve path torestic binary""" env_paths = os.environ.get('PATH', '').split(os.pathsep) for env_path in env_paths: exec_bin = pathlib.Path(env_path) / 'restic' if not exec_bin.is_file(): continue return exec_bin return '' # pylint: disable=unused-argument def run_pre_operation_check(rconf, args): """validate authentication, repo access, and configuration""" log.info('validating config') if not check_auth(): return False status, _, stderr = run( rconf.exec_bin, 'cat', 'config', *rconf.base_args, cstderr=1, cstdout=1) if not status: log.error('pre repo check failed: %s', stderr) return False return True def check_auth(): """check authentication with restic backend""" sec_key = os.environ.get('AWS_SECRET_ACCESS_KEY') access_key = os.environ.get('AWS_ACCESS_KEY_ID') cred_file = os.environ.get('AWS_SHARED_CREDENTIALS_FILE') if sec_key and access_key: return True if cred_file: return True log.error(""" AWS_SECRET_ACCESS_KEY and AWS_ACCESS_KEY_ID or AWS_SHARED_CREDENTIALS_FILE are required. """) return False def run_backup(rconf, argz): """backup a given directory""" try: log.info('restic-backup start') run(rconf.exec_bin, 'backup', '--exclude-caches', *rconf.exclude, argz['path'], *rconf.base_args) except Exception as error: log.error('restic-run failed %s', error) raise error log.info('restic-backup finished') return True def run_prune(rconf, argz): """run prune task""" try: run(rconf.exec_bin, 'prune', *rconf.base_args) # pylint: disable=broad-exception-caught except Exception as error: log.error('resitc-prune %s', error) return False return True def run_forget(rconf, argz): """forget backups that are no longer needed""" try: status, _, stderr = run(rconf.exec_bin, 'forget', '--keep-daily', 7, '--keep-weekly', 5, '--keep-monthly', 12, '--keep-yearly', 75, *rconf.base_args) if not status: log.error('restic-prune %s', stderr) return False # pylint: disable=broad-exception-caught except Exception as error: log.error('retsic-prune %s', error) return False return True def run_check(rconf, args): """restic check command""" try: status, _, stderr = run(rconf.exec_bin, 'check', *rconf.base_args) if not status: log.error('restic-check %s', stderr) return False # pylint: disable=broad-exception-caught except Exception as error: log.error('restic-check %s', error) return False return True # def follow(logfile, pattern): # # catch up # for line in logfile.readlines(): # if pattern in line: # yield line # logfile.seek(0, 2) # while True: # line = logfile.readline() # if not line: # time.sleep(0.1) # continue # if pattern in line: # yield line # # def run_watch(args): logfile = open("restic.log", "r") loglines = follow(logfile, 'ERROR') for line in loglines: print(line) def main(): """main""" argz = prepare_args() # ResticCmdConfig restic_config = ResticCmdConfig( resolve_restic(), [ '--password-file', argz['password_file'], '--repo', f'{argz["repo"]}/{argz["namespace"]}', ], [ '--exclude-file', argz['exclude_file'], ] ) try: success = argz['func'](restic_config, argz) # pylint: disable=broad-exception-caught except Exception as error: log.error('error running %s', error) sys.exit(1) if not success: sys.exit(1) sys.exit(0)