resticbackup/resticbackup/cli.py

249 lines
8.5 KiB
Python

import os
import argparse
import subprocess
import traceback
import time
from resticbackup import __version__
from resticbackup.types import ClientConfig, load_config, load_base_config, list_configs
from resticbackup.lib import fprint, ExecWrapper, init_ok, die, pdie, checkp, runp, get_retention_args, \
get_newest_snapshot, update_statefile
def cmd_backup(args, parser):
config = load_config(args.name)
try:
# run the backup
backoff(run_backup, args.name, config)
# run retention
if backoff(run_retention, args.name, config):
# run prune (optional)
if args.prune:
backoff(run_prune, config)
# update statefile if configured
if config.statefile:
path = config.statefile.format(name=args.name)
update_statefile(path, True)
except:
if config.statefile:
path = config.statefile.format(name=args.name)
update_statefile(path, False)
raise
def run_backup(name, config):
# these tags will be added to the backup.
# They need to match retention_tags in run_rentention, below
backup_tags = {
"name": name,
}
# perform pre/post-exec
# post-exec is executed if the backup fails OR if any pre-exec commands fail
# pre-exec commands failing will stop the backup from being executed
os.chdir(config.backup.path)
with ExecWrapper(post=config.backup.backup_postexec):
with ExecWrapper(pre=config.backup.backup_preexec):
proc = config.run(["init"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
stdout, _ = proc.communicate()
stdout = stdout.decode()
if not init_ok(stdout):
raise Exception(stdout)
# run backup
backup_cmd = ["backup", "./"]
for k, v in backup_tags.items():
backup_cmd.extend(["--tag", "{}={}".format(k, v)])
for entry in config.backup.exclude:
if entry.startswith("/"):
entry = os.path.join(config.backup.path, entry[1:])
backup_cmd.extend(["--exclude", entry])
checkp(config.run(backup_cmd))
def backoff(func, *args, **kwargs):
"""
run the retention function, but retry if it fails. Restic must lock the entire repo during this process, so it is
possible that it fails due to already being in use by another client
"""
backoff = [1, 2, 3, 5, 10, 15, 30, 60]
backoffs = 0
while True:
try:
return func(*args, **kwargs)
except:
if backoffs >= len(backoff):
raise
traceback.print_exc()
delay = backoff[backoffs]
backoffs += 1
fprint("...caught, and will be retried in {}m".format(delay))
time.sleep(delay * 60)
def run_retention(name, config):
"""
run retention, which untags old backups. Returns true if retention is configured and was executed, false otherwise
"""
# the tags in retention_tags will be used when pruning backups.
# They need to match backup_tags in run_backup, above
retention_tags = {
"name": name,
}
# perform retention
retention_args = get_retention_args(config.backup.schedule)
if retention_args:
forget_cmd = ["forget", "--group-by", "tags"] + retention_args
for k, v in retention_tags.items():
forget_cmd.extend(["--tag", "{}={}".format(k, v)])
runp(config.run(forget_cmd))
return True
return False
def run_prune(config):
# perform junk deletion
runp(config.run(["prune"]))
def cmd_restore(args, parser):
config = load_config(args.name)
try:
if os.listdir(config.backup.path):
return die("output dir has files present - refusing to restore")
except FileNotFoundError:
return die("output dir '{}' does not exist".format(config.backup.path))
snapshot = get_newest_snapshot(args.name, config)
if not snapshot:
if args.no_snapshots_ok:
fprint("no snapshots found, but exiting OK")
return
else:
return die("no snapshots found")
os.chdir(config.backup.path)
with ExecWrapper(post=config.backup.restore_postexec):
with ExecWrapper(pre=config.backup.restore_preexec):
return pdie(config.run(["restore", snapshot["id"], "-t", "./"]))
def cmd_retrieve(args, parser):
# this is the same as cmd_restore except for how the config is formed and no execs
config = ClientConfig.load(load_base_config(), {"repo": args.repo, "path": args.outdir})
try:
if os.listdir(config.backup.path):
return die("output dir has files present - refusing to restore")
except FileNotFoundError:
return die("output dir does not exist")
snapshot = get_newest_snapshot(args.name, config)
if not snapshot:
return die("no snapshots found")
return pdie(config.run(["restore", snapshot["id"], "-t", args.outdir]))
def cmd_list(args, parser):
if args.name:
config = load_config(args.name)
cmd = ["snapshots", "--group-by", "tags"]
retention_tags = {
"name": args.name,
}
for k, v in retention_tags.items():
cmd.extend(["--tag", "{}={}".format(k, v)])
return pdie(config.run(cmd))
for entry in list_configs():
fprint(entry)
def cmd_exec(args, parser):
checkp(load_config(args.name).run(args.args))
def cmd_prune(args, parser):
if args.name is None:
names = list_configs()
else:
names = [args.name]
configs = [load_config(n) for n in names]
# we only need to prune each repo once, so use a dict to group configs by repo address
configs = {c.repo: c for c in configs}
for config in configs.values():
fprint("pruning:", config.repo)
run_prune(config)
def cmd_version(args, parser):
fprint(__version__)
def main():
parser = argparse.ArgumentParser(description="restic wrapper")
# parser.add_argument('-n', '--no-exec', action='store_true', help='do not run pre/post-exec commands') # TODO
# parser.add_argument('-b', '--no-pre-exec', action='store_true', help='do not run pre-exec commands') # TODO
# parser.add_argument('-m', '--no-post-exec', action='store_true', help='do not run post-exec commands') # TODO
sp_action = parser.add_subparsers(dest="action", help="action to take")
p_backup = sp_action.add_parser("backup", help="take a backup")
p_backup.set_defaults(func=cmd_backup)
p_backup.add_argument("name", help="name of backup to execute")
p_backup.add_argument('--prune', action='store_true', help='run the prune operation after backup')
# p_backup.add_argument("-c", "--print", action="store_true", help="just print the restic commands instead") # TODO
# p_backup.add_argument("-e", "--env", action="store_true", help="just print the environment variables instead") # TODO
p_restore = sp_action.add_parser("restore", help="restore a backup")
p_restore.set_defaults(func=cmd_restore)
p_restore.add_argument("name", help="name of backup to restore")
# p_restore.add_argument("snapshot", nargs="?", help="id of snapshot to restore") # TODO
p_restore.add_argument('--no-snapshots-ok', action='store_true',
help='don\'t return an error if no snapshots are available to be restored')
p_exec = sp_action.add_parser("exec", help="execute a restic command")
p_exec.set_defaults(func=cmd_exec)
p_exec.add_argument("name", help="name of backup to configure restic for")
p_exec.add_argument("args", nargs="+", help="command arguments")
p_list = sp_action.add_parser("list", help="list configs or snapshots")
p_list.set_defaults(func=cmd_list)
p_list.add_argument("name", nargs="?", help="name of config to list snapshots for")
p_retrieve = sp_action.add_parser("retrieve", help="download arbitrary snapshots")
p_retrieve.set_defaults(func=cmd_retrieve)
p_retrieve.add_argument("repo", help="repo path")
p_retrieve.add_argument("name", help="backup name")
p_retrieve.add_argument("outdir", help="file path to write to")
p_prune = sp_action.add_parser("prune", help="run the prune function")
p_prune.add_argument("name", nargs="?", help="name of backup to run prune in repo for. Omit for all repos")
p_prune.set_defaults(func=cmd_prune)
p_version = sp_action.add_parser("version", help="display program version")
p_version.set_defaults(func=cmd_version)
args = parser.parse_args()
args.func(args, parser)
if __name__ == '__main__':
main()