rbackup/bin/backup
2019-04-12 10:48:39 -07:00

213 lines
6.2 KiB
Python

#!/usr/bin/python3
"""
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
* -s, --run-post-sync run sync syscall after backup
* -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 re
import sys
from contextlib import contextmanager
from pathlib import Path
from subprocess import CalledProcessError
from tempfile import NamedTemporaryFile
from rbackup.rsync import rsync
from rbackup.struct.repository import Repository
# ========== Constants ==========
LOGFORMAT = "==> %(levelname)s %(message)s"
RSYNC_DEFAULT_OPTS = [
"--acls",
"--archive",
"--backup",
"--backup-dir=backup",
"--hard-links",
"--ignore-missing-args",
"--prune-empty-dirs",
"--suffix=.old",
"--recursive",
"--xattrs",
]
EXTRA_RSYNC_OPTS = {
"dry_run": "--dry-run",
"delete": "--delete-after",
"checksum": "--checksum",
"update": "--update",
}
# ----- File Options -----
CONFIG_DIR = "/etc/rbackup"
FILE_OPTS = [f"--exclude-from={CONFIG_DIR}/home-exclude.conf"]
INCLUDE_PATHS = [f"{CONFIG_DIR}/etc-include.conf", f"{CONFIG_DIR}/system-include.conf"]
COMMENT_REGEX = r"^[^#; ]+"
# ----- Error Codes -----
E_FAILED_PROCESS = 1
E_INVALID_SNAPSHOT_NAME = 2
# ========== 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(
"-s",
"--run-post-sync",
action="store_true",
help="run sync operation after backup is complete",
)
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 merge_files(*config_files):
"""Parse, filter, and sort through config files to create a single
--files-from argument.
Any files included that do not exist send a warning to the log.
>>> merge_files('/etc/rbackup/etc-include.conf', '/etc/rbackup/system-include.conf') # doctest: +ELLIPSIS
>>> '/tmp/...'
:param config_files: files including paths to read from
:type config_files: iterable of str
:returns: path to file that lists include paths
:rtype: str
"""
file_paths = [Path(p) for p in config_files]
include_lines = []
for config_file in file_paths:
if config_file.exists():
with config_file.open(mode="r") as opened_file:
include_lines.extend(
l for l in opened_file.readlines() if re.match(COMMENT_REGEX, l)
)
else:
syslog.warning(f"{config_file} does not exist, ignoring.")
include_lines.sort()
with NamedTemporaryFile(mode="w", delete=False) as include_paths:
include_paths.writelines(include_lines)
tmpfile = Path(include_paths.name)
yield tmpfile
tmpfile.unlink()
# ========== Main Script ==========
if __name__ == "__main__":
args = parse_cmdline_arguments()
repo = Repository(args.repository)
rsync_opts = RSYNC_DEFAULT_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
try:
with merge_files(*INCLUDE_PATHS) as include_file:
curr_snapshot = repo.create_snapshot(args.name)
rsync(
*rsync_opts,
*FILE_OPTS,
f"--files-from={include_file}",
*link_dests,
"/",
str(curr_snapshot.path),
)
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}")
exit(E_FAILED_PROCESS)
snapshot_symlink = repo.path / "current"
try:
snapshot_symlink.unlink()
snapshot_symlink.symlink_to(curr_snapshot, target_is_directory=True)
except FileNotFoundError:
pass
except PermissionError as e:
syslog.error(e)
if args.run_post_sync:
syslog.info("Running sync operation")
os.sync()