#!/usr/bin/python3 """ .. moduleauthor:: Eric Torres Run a backup, creating a snapshot in the process. Command-Line Arguments ====================== -c, --use-checksums use rsync's checksum feature to detect file changes -d, --dry-run make this backup a dry run --debug show debug messages -n, --name name to give to the backup snapshot -p, --port port that ssh on the destination is listening on -s, --run-post-sync run sync syscall after backup -u, --umask umask value to use while running backup process -v, --verbose show info messages On each run of this script, a new snapshot is made and any unchanged files are hardlinked into the new snapshot. """ import argparse import logging import os import sys from contextlib import contextmanager from subprocess import CalledProcessError import rbackup.config.config_files as config import rbackup.rsync from rbackup.struct.repository import Repository # ========== Constants ========== SCRIPT_UMASK = 0000 LOGFORMAT = "==> %(levelname)s %(message)s" EXTRA_RSYNC_OPTS = { "dry_run": "--dry-run", "delete": "--delete-after", "checksum": "--checksum", "update": "--update", } # ----- Error Codes ----- E_INVALID_SNAPSHOT_NAME = 2 E_PERMISSION = 13 # ========== Logging Setup ========== console_formatter = logging.Formatter(LOGFORMAT) syslog = logging.getLogger("rbackup") syslog.setLevel(logging.DEBUG) stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setLevel(logging.INFO) stdout_handler.setFormatter(console_formatter) stdout_handler.addFilter(lambda record: record.levelno <= logging.INFO) stderr_handler = logging.StreamHandler(sys.stderr) stderr_handler.setLevel(logging.WARNING) stderr_handler.setFormatter(console_formatter) syslog.addHandler(stdout_handler) syslog.addHandler(stderr_handler) # ========== Functions ========== def parse_cmdline_arguments(**kwargs): """Parse command line arguments passed to the script. All kwargs are passed to ArgumentParser.parse_args(). :rtype: argparse.Namespace object """ parser = argparse.ArgumentParser() parser.add_argument( "-c", "--use-checksums", action="store_const", dest="extra_rsync_opts", const=EXTRA_RSYNC_OPTS["checksum"], help="use rsync's checksumming feature to look for changed files", ) parser.add_argument( "-d", "--dry-run", action="append_const", dest="extra_rsync_opts", const=EXTRA_RSYNC_OPTS["dry_run"], help="pass --dry-run to rsync", ) parser.add_argument("--debug", action="store_true", help="log debug messages") parser.add_argument( "-n", "--name", default=None, help="name to give to the snapshot" ) parser.add_argument( "-p", "--port", default=22, help="port that ssh on the destination is listening on" ) parser.add_argument( "-s", "--run-post-sync", action="store_true", help="run sync operation after backup is complete", ) parser.add_arguemnt( "-u", "--umask", type=int, default=None, help="umask value to use while running backup process" ) parser.add_argument( "-v", "--verbose", action="store_true", help="log info messages" ) parser.add_argument("repository", help="repository to back up to", metavar="repo") return parser.parse_args(**kwargs) @contextmanager def change_umask(override=None): """Creates a context manager in which the umask is changed. This is to ensure that the script's desired umask is not visible to the user. :param override: non-script-default umask value to use :type override: int """ try: if override is not None: old_umask = os.umask(override) else: old_umask = os.umask(SCRIPT_UMASK) yield finally: os.umask(old_umask) # ========== Main Script ========== if __name__ == "__main__": args = parse_cmdline_arguments() parsed_config = config.parse_configfile() repo = None try: repo = Repository(args.repository) except PermissionError as e: syslog.critical(e) exit(E_PERMISSION) rsync_opts = config.load_list_from_option( parsed_config, section="main", option="RsyncOptions", fallback=rbackup.rsync.DEFAULT_RSYNC_OPTS.copy(), ) if args.extra_rsync_opts is not None: rsync_opts.extend(args.extra_rsync_opts) if args.verbose: stdout_handler.setLevel(logging.INFO) if args.debug: stdout_handler.setLevel(logging.DEBUG) # We want to iterate through the repository and create the --link-dest # options before creating the new snapshot link_dests = tuple(f"--link-dest={s.path}" for s in repo) curr_snapshot = None with change_umask(args.umask), config.merge_include_files() as include_file, config.merge_exclude_files() as exclude_file: try: curr_snapshot = repo.create_snapshot(args.name) rbackup.rsync.rsync( *rsync_opts, f"--files-from={include_file}", f"--exclude-from={exclude_file}", *link_dests, "/", str(curr_snapshot), ) except ValueError as e: syslog.critical(e) exit(E_INVALID_SNAPSHOT_NAME) except CalledProcessError as e: syslog.critical("Backup process failed") syslog.critical(f"Failing command: {e.cmd}") syslog.critical(e.stderr) exit(e.returncode) if args.run_post_sync: syslog.info("Running sync operation") os.sync()