Update backup manager script

This commit is contained in:
Ethanell 2022-10-18 00:02:58 +02:00
parent dca2dbdad8
commit 4cbaf0c52f

631
backup-manager.py Executable file → Normal file
View file

@ -1,6 +1,6 @@
#!/usr/bin/python -W ignore::DeprecationWarning
#!/usr/bin/python3
#
# Script to manage S3-stored backups
# Script to manage B2-stored backups
#
# Copyright (c) 2009-2013 Ryan S. Tucker
#
@ -22,33 +22,37 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import optparse
import os
import pwd
import secrets
import sys
import time
from boto.s3.connection import S3Connection
from argparse import ArgumentParser
from collections import defaultdict
from math import log10
from datetime import datetime, timedelta
from os import environ, getuid
from pathlib import Path
from pwd import getpwuid
from subprocess import Popen
from sys import argv
from b2sdk.v2 import FileVersion
from b2sdk.v2 import InMemoryAccountInfo, B2Api, Bucket
from math import log10
from progress.bar import ChargingBar
import secrets
class BackupManager:
def __init__(self, accesskey, sharedkey):
self._accesskey = accesskey
self._connection = S3Connection(accesskey, sharedkey)
def __init__(self, access_key: str, shared_key: str):
self._access_key = access_key
b2_info = InMemoryAccountInfo()
self._connection = B2Api(b2_info)
self._connection.authorize_account("production", self._access_key, shared_key)
self._buckets = None
self._bucketbackups = {}
self._bucket_backups = {}
self._backups = None
def _generate_backup_buckets(self):
bucket_prefix = self._accesskey.lower() + '-bkup-'
buckets = self._connection.get_all_buckets()
def _generate_backup_buckets(self) -> [Bucket]:
bucket_prefix = f"{self._access_key}-bckpc-".lower()
buckets = self._connection.list_buckets()
self._buckets = []
for bucket in buckets:
@ -56,20 +60,24 @@ class BackupManager:
self._buckets.append(bucket)
@property
def backup_buckets(self): # property
def backup_buckets(self) -> [Bucket]:
if self._buckets is None:
self._generate_backup_buckets()
return self._buckets
def _list_backups(self, bucket):
"""Returns a dict of backups in a bucket, with dicts of:
@staticmethod
def _list_backups(bucket: Bucket) -> {}:
"""
Returns a dict of backups in a bucket, with dicts of:
{hostname (str):
{Backup number (int):
{'date': Timestamp of backup (int),
'keys': A list of keys comprising the backup,
'hostname': Hostname (str),
'backupnum': Backup number (int),
'finalized': 0, or the timestamp the backup was finalized
{
"date": Datetime of backup (int),
"files": A list of files comprising the backup,
"hostname": Hostname (str),
"backup_num": Backup number (int),
"finalized": 0, or the timestamp the backup was finalized,
"bucket": the bucket of the backup
}
}
}
@ -77,120 +85,123 @@ class BackupManager:
backups = {}
for key in bucket.list():
keyparts = key.key.split('.')
for file in filter(lambda e: isinstance(e, FileVersion), map(lambda e: e[0], bucket.ls())):
file: FileVersion = file
parts = file.file_name.split(".")
final = False
if keyparts[-1] == 'COMPLETE':
if parts[-1] == "COMPLETE":
final = True
keyparts.pop() # back to tar
keyparts.pop() # back to backup number
parts.pop() # back to tar
parts.pop() # back to backup number
else:
if keyparts[-1] == 'gpg':
keyparts.pop()
if parts[-1] == "gpg":
parts.pop()
if keyparts[-1] != 'tar' and len(keyparts[-1]) is 2:
keyparts.pop()
if parts[-1] != "tar" and len(parts[-1]) == 2:
parts.pop()
if keyparts[-1] == 'tar':
keyparts.pop()
if parts[-1] == "tar":
parts.pop()
nextpart = keyparts.pop()
if nextpart == 'COMPLETE':
print("Stray file: %s" % key.key)
nextpart = parts.pop()
if nextpart == "COMPLETE":
print(f"Stray file: {file.file_name}")
continue
backupnum = int(nextpart)
hostname = '.'.join(keyparts)
backup_num = int(nextpart)
hostname = ".".join(parts)
lastmod = time.strptime(key.last_modified,
'%Y-%m-%dT%H:%M:%S.000Z')
upload_timestamp = file.upload_timestamp//1000
lastmod = datetime.utcfromtimestamp(upload_timestamp)
if hostname in backups.keys():
if not backupnum in backups[hostname].keys():
backups[hostname][backupnum] = {
'date': lastmod,
'hostname': hostname,
'backupnum': backupnum,
'finalized': 0,
'keys': [],
'finalkey': None,
'finalized_age': -1,
if backup_num not in backups[hostname].keys():
backups[hostname][backup_num] = {
"date": lastmod,
"hostname": hostname,
"backup_num": backup_num,
"finalized": 0,
"files": [],
"final_file": None,
"finalized_age": -1,
"bucket": bucket
}
else:
backups[hostname] = {
backupnum: {
'date': lastmod,
'hostname': hostname,
'backupnum': backupnum,
'finalized': 0,
'keys': [],
'finalkey': None,
'finalized_age': -1,
backup_num: {
"date": lastmod,
"hostname": hostname,
"backup_num": backup_num,
"finalized": 0,
"files": [],
"final_file": None,
"finalized_age": -1,
"bucket": bucket
}
}
if final:
backups[hostname][backupnum]['finalized'] = lastmod
backups[hostname][backupnum]['finalkey'] = key
timestamp = time.mktime(lastmod)
delta = int(time.time() - timestamp + time.timezone)
backups[hostname][backupnum]['finalized_age'] = delta
backups[hostname][backup_num]["finalized"] = upload_timestamp
backups[hostname][backup_num]["final_file"] = file
delta = int((lastmod - datetime.now()).total_seconds() * 1000000)
backups[hostname][backup_num]["finalized_age"] = delta
else:
if lastmod < backups[hostname][backupnum]['date']:
backups[hostname][backupnum]['date'] = lastmod
backups[hostname][backupnum]['keys'].append(key)
if lastmod < backups[hostname][backup_num]["date"]:
backups[hostname][backup_num]["date"] = lastmod
backups[hostname][backup_num]["files"].append(file)
return backups
def get_backups_by_bucket(self, bucket):
if bucket.name not in self._bucketbackups:
self._bucketbackups[bucket.name] = self._list_backups(bucket)
def get_backups_by_bucket(self, bucket: Bucket) -> {}:
if bucket.name not in self._bucket_backups:
self._bucket_backups[bucket.name] = self._list_backups(bucket)
return self._bucketbackups[bucket.name]
return self._bucket_backups[bucket.name]
@property
def all_backups(self): # property
def all_backups(self) -> [{}]:
if self._backups is None:
sys.stderr.write("Enumerating backups")
self._backups = {}
for bucket in self.backup_buckets:
backups_dict = self.get_backups_by_bucket(bucket)
for hostname, backups in backups_dict.items():
sys.stderr.write('.')
sys.stderr.flush()
if hostname not in self._backups:
self._backups[hostname] = {}
self._backups[hostname].update(backups)
sys.stderr.write("\n")
return self._backups
def invalidate_host_cache(self, hostname):
nuke = []
for bucket in self._bucketbackups:
if hostname in self._bucketbackups[bucket]:
for bucket in self._bucket_backups:
if hostname in self._bucket_backups[bucket]:
nuke.append(bucket)
for bucket in nuke:
if bucket in self._bucketbackups:
del self._bucketbackups[bucket]
if bucket in self._bucket_backups:
del self._bucket_backups[bucket]
self._backups = None
@property
def backups_by_age(self): # property
"Returns a dict of {hostname: [(backupnum, age), ...]}"
def backups_by_age(self):
"""
Returns a dict of {hostname: [(backup_num, age), ...]}
"""
results = defaultdict(list)
for hostname, backups in self.all_backups.items():
for backupnum, statusdict in backups.items():
results[hostname].append((backupnum,
statusdict['finalized_age']))
for backup_num, statusdict in backups.items():
results[hostname].append((backup_num,
statusdict["finalized_age"]))
return results
def choose_host_to_backup(agedict, target_count=2):
"Takes a dict from backups_by_age, returns a hostname to back up."
def choose_host_to_backup(age_dict, target_count=2):
"""
Takes a dict from backups_by_age, returns a hostname to back up.
"""
host_scores = defaultdict(int)
for hostname, backuplist in agedict.items():
bl = sorted(backuplist, key=lambda x: x[1])
for hostname, backup_list in age_dict.items():
bl = sorted(backup_list, key=lambda x: x[1])
if len(bl) > 0 and bl[0][1] == -1:
# unfinalized backup alert
host_scores[hostname] += 200
@ -199,20 +210,22 @@ def choose_host_to_backup(agedict, target_count=2):
host_scores[hostname] -= 100
host_scores[hostname] -= len(bl)
if len(bl) > 0:
# age of oldest backup helps score
# age of the oldest backup helps score
oldest = bl[0]
host_scores[hostname] += log10(oldest[1])
# recency of newest backup hurts score
# recency of the newest backup hurts score
newest = bl[-1]
host_scores[hostname] -= log10(max(1, (oldest[1] - newest[1])))
for candidate, score in sorted(host_scores.items(),
key=lambda x: x[1], reverse=True):
yield (candidate, score)
yield candidate, score
def choose_backups_to_delete(agedict, target_count=2, max_age=30):
"Takes a dict from backups_by_age, returns a list of backups to delete"
"""
Takes a dict from backups_by_age, returns a list of backups to delete
"""
decimate = defaultdict(list)
@ -230,279 +243,233 @@ def choose_backups_to_delete(agedict, target_count=2, max_age=30):
return decimate
def iter_urls(keyset, expire=86400):
"""Given a list of keys and an optional expiration time (in seconds),
returns an iterator of URLs to fetch to reassemble the backup."""
def make_restore_script(backup: {}, expire=86400) -> str:
"""
Returns a quick and easy restoration script to restore the given system,
requires a backup, and perhaps expire
"""
for key in keyset:
yield key.generate_url(expires_in=expire)
hostname = backup["hostname"]
backup_num = backup["backup_num"]
bucket = backup["bucket"]
friendly_time = backup["date"].strftime("%Y-%m-%d at %H:%M GMT")
expire_time = datetime.now() + timedelta(seconds=expire)
def make_restore_script(backup, expire=86400):
"""Returns a quick and easy restoration script to restore the given system,
requires a backup, and perhaps expire"""
files = [f"'{bucket.get_download_url(i.file_name)}'" for i in backup["files"] + [backup["final_file"]]]
myhostname = backup['hostname']
mybackupnum = backup['backupnum']
myfriendlytime = time.strftime('%Y-%m-%d at %H:%M GMT', backup['date'])
myexpiretime = time.strftime('%Y-%m-%d at %H:%M GMT',
time.gmtime(time.time() + expire))
myexpiretimestamp = time.time() + expire
output = f"""#!/bin/bash
# Restoration script for {hostname} backup {backup_num},
# a backup created on {friendly_time}.
# To use: bash scriptname /path/to/put/the/files
output = []
# WARNING: THIS FILE EXPIRES AFTER {expire_time.strftime("%Y-%m-%d at %H:%M GMT")}
if (( "$(date +%s)" > "{int(expire_time.timestamp() * 1000000)}" )); then
echo "Sorry, this restore script is too old."
exit 1
elif [ -z "$1" ]; then
echo "Usage: ./scriptname /path/to/restore/to"
exit 1
elif [ ! -d "$1" ]; then
echo "Target $1 does not exist!"
exit 1
elif [ -n "$(ls --almost-all "$1")" ]; then
echo "Target $1 is not empty!"
exit 1
fi
output.append('#!/bin/sh\n')
output.append('# Restoration script for %s backup %s,\n' % (
myhostname, mybackupnum))
output.append('# a backup created on %s.\n' % (myfriendlytime))
output.append('# To use: bash scriptname /path/to/put/the/files\n\n')
output.append('# WARNING: THIS FILE EXPIRES AFTER %s\n' % (myexpiretime))
output.append('if [ "`date +%%s`" -gt "%i" ];\n' % (myexpiretimestamp))
output.append(' then echo "Sorry, this restore script is too old.";\n')
output.append(' exit 1;\n')
output.append('fi\n\n')
output.append('if [ -z "$1" ];\n')
output.append(' then echo "Usage: ./scriptname /path/to/restore/to";\n')
output.append(' exit 1;\n')
output.append('fi\n\n')
output.append('# Check the destination\n')
output.append('if [ ! -d $1 ];\n')
output.append(' then echo "Target $1 does not exist!";\n')
output.append(' exit 1;\n')
output.append('fi\n\n')
output.append('if [ -n "`ls --almost-all $1`" ];\n')
output.append(' then echo "Target $1 is not empty!";\n')
output.append(' exit 1;\n')
output.append('fi\n\n')
output.append('# cd to the destination, create a temporary workspace\n')
output.append('cd $1\n')
output.append('mkdir .restorescript-scratch\n\n')
output.append('# retrieve files\n')
# cd to the destination, create a temporary workspace
cd "$1"
tmp_dir="$i/.restorescript-scratch"
mkdir "$tmp_dir"
mysortedfilelist = []
for key in backup['keys']:
output.append('wget -O $1/.restorescript-scratch/%s "%s"\n' % (
key.name, key.generate_url(expires_in=expire)))
mysortedfilelist.append('.restorescript-scratch/' + key.name)
mysortedfilelist.sort()
files=({' '.join(files)})
token='{bucket.get_download_authorization(f'{hostname}.{backup_num}', expire)}'
output.append('\n# decrypt files\n')
output.append('gpg --decrypt-files << EOF\n')
output.append('\n'.join(mysortedfilelist))
output.append('\nEOF\n')
declare a out_files
for i in "${{files[@]}}"; do
filename="$(echo "$i" | cut -d/ -f6)"
curl "$i" -o "$tmp_dir/$filename" -H "Authorization: $token"
if (( $? != 0 )); then
echo "Error during download !"
exit 1
fi
out_files+=("$tmp_dir/$filename")
done
output.append('\n# join and untar files\n')
output.append('cat .restorescript-scratch/*.tar.?? | tar -xf -\n\n')
# decrypt files
gpg --decrypt-files "${{out_files[@]}}"
output.append('echo "DONE! Have a nice day."\n##\n')
# join and untar files
cat "$tmp_dir/*.tar.??" | tar -xf -
echo "DONE! Have a nice day."
"""
return output
def start_archive(hosts):
"Starts an archive operation for a list of hosts."
if 'LOGNAME' in os.environ:
username = os.environ['LOGNAME']
"""
Starts an archive operation for a list of hosts.
"""
if "LOGNAME" in environ:
username = environ["LOGNAME"]
else:
try:
username = pwd.getpwuid(os.getuid()).pw_name
username = getpwuid(getuid()).pw_name
except KeyError:
username = 'nobody'
username = "nobody"
scriptdir = os.path.dirname(sys.argv[0])
cmd = [os.path.join(scriptdir, 'BackupPC_archiveStart'), 'archives3',
username]
cmd = [Path(argv[0]).parents[0] / "BackupPC_archiveStart", "archives3", username]
cmd.extend(hosts)
proc = Popen(cmd)
proc.communicate()
def main():
# check command line options
parser = optparse.OptionParser(
usage="usage: %prog [options] [list|delete|script]",
description="" +
"Companion maintenance script for BackupPC_archiveHost_s3. " +
"By default, it assumes the 'list' command, which displays all " +
"of the backups currently archived on S3. The 'delete' command " +
"is used to delete backups. The 'script' command produces a " +
"script that can be used to download and restore a backup.")
parser.add_option("-H", "--host", dest="host",
help="Name of backed-up host")
parser.add_option("-b", "--backup-number", dest="backupnum",
help="Backup number")
parser.add_option("-a", "--age", dest="age",
help="Delete backups older than AGE days")
parser.add_option("-k", "--keep", dest="keep",
help="When used with --age, keep this many recent " +
"backups (default=1)", default=1)
parser.add_option("-f", "--filename", dest="filename",
help="Output filename for script")
parser.add_option("-x", "--expire", dest="expire",
help="Maximum age of script, default 86400 seconds")
parser.add_option("-t", "--test", dest="test", action="store_true",
help="Test mode; don't actually delete")
parser.add_option("-u", "--unfinalized", dest="unfinalized",
action="store_true", help="Consider unfinalized backups")
parser.add_option("-s", "--start-backups", dest="start",
action="store_true",
help="When used with --age, start backups for hosts " +
"with fewer than keep+1 backups")
parser.add_option("-l", "--list", dest="list", action="store_true",
help="List stored backups after completing operations")
(options, args) = parser.parse_args()
bmgr = BackupManager(secrets.accesskey, secrets.sharedkey)
if options.backupnum and not options.host:
parser.error('Must specify --host when specifying --backup-number')
if options.backupnum:
options.backupnum = int(options.backupnum)
if len(args) == 0:
args.append('list')
if len(args) > 1:
parser.error('Too many arguments.')
if args[0] != 'delete' and options.age:
parser.error('--age only makes sense with delete')
if options.start and not (args[0] == 'delete' and options.age):
parser.error('--start-backups only makes sense with delete and --age')
if args[0] != 'script' and (options.expire or options.filename):
parser.error('--expire and --filename only make sense with script')
if args[0] in ['list', 'script', 'delete']:
if options.host:
if options.host not in bmgr.all_backups:
parser.error('No backups found for host "%s"' % options.host)
else:
if len(bmgr.all_backups) == 0:
parser.error('No buckets found!')
else:
parser.error('Invalid option: %s' + args[0])
if args[0] == 'script':
if not options.host:
parser.error('Must specify --host to generate a script for')
if not options.backupnum and options.unfinalized:
def script(parser: ArgumentParser, bmgr: BackupManager, host: str, unfinalized: bool, backup_num: int = None,
expire: int = 86400, filename: str = None):
if not backup_num and unfinalized:
# assuming highest number
options.backupnum = max(bmgr.all_backups[options.host].keys())
elif not options.backupnum:
backup_num = max(bmgr.all_backups[host].keys())
elif not backup_num:
# assuming highest finalized number
options.backupnum = 0
for backup in bmgr.all_backups[options.host].keys():
if bmgr.all_backups[options.host][backup]['finalized'] > 0:
options.backupnum = max(options.backupnum, backup)
if options.backupnum == 0:
parser.error('No finalized backups found! Try '
'--unfinalized if you dare')
backup_num = 0
for backup in bmgr.all_backups[host].keys():
if bmgr.all_backups[host][backup]["finalized"] > 0:
backup_num = max(backup_num, backup)
if backup_num == 0:
parser.error("No finalized backups found! Try --unfinalized if you dare")
backup = bmgr.all_backups[options.host][options.backupnum]
backup = bmgr.all_backups[host][backup_num]
if not options.expire:
options.expire = "86400"
if options.filename:
fd = open(options.filename, 'w')
fd.writelines(make_restore_script(backup,
expire=int(options.expire)))
if filename:
with open(filename, "w") as fd:
fd.write(make_restore_script(backup, expire=expire))
else:
sys.stdout.writelines(make_restore_script(backup,
expire=int(options.expire)))
elif args[0] == 'delete':
to_ignore = int(options.keep)
print(make_restore_script(backup, expire=expire))
def delete(bm: BackupManager, keep: int, host: str, backup_num: int, age: int, test: bool,
start: bool):
to_delete = []
if options.host and options.backupnum:
print("Will delete backup: %s %i (forced)" % (
options.host, options.backupnum))
to_delete.append((options.host, options.backupnum))
elif options.age:
to_delete_dict = choose_backups_to_delete(bmgr.backups_by_age,
target_count=to_ignore,
max_age=int(options.age))
for hostname, backuplist in to_delete_dict.items():
for backupstat in backuplist:
print("Will delete backup: %s %i (expired at %g days)" % (
hostname, backupstat[0], backupstat[1] / 86400.0))
to_delete.append((hostname, backupstat[0]))
if host and backup_num:
print(f"Will delete backup: {host} {backup_num} (forced)")
to_delete.append((host, backup_num))
elif age:
to_delete_dict = choose_backups_to_delete(bm.backups_by_age, target_count=keep, max_age=age)
for hostname, backup_list in to_delete_dict.items():
for backup_stat in backup_list:
print(f"Will delete backup: {hostname} {backup_stat[0]} (expired at {backup_stat[1] / 86400.0} days)")
to_delete.append((hostname, backup_stat[0]))
else:
parser.error('Need either an age or a host AND backup number.')
return
if len(to_delete) > 0:
for deletehost, deletebackupnum in to_delete:
hostbackups = bmgr.all_backups.get(deletehost, {})
deletebackup = hostbackups.get(deletebackupnum, {})
deletekeys = deletebackup.get('keys', [])
finalkey = deletebackup.get('finalkey', None)
if len(deletekeys) > 0:
sys.stdout.write("Deleting backup: %s %d (%d keys)" % (
deletehost, deletebackupnum, len(deletekeys)))
for key in deletekeys:
if options.test:
sys.stdout.write('_')
else:
key.delete()
sys.stdout.write('.')
sys.stdout.flush()
if finalkey is not None:
if options.test:
sys.stdout.write('+')
else:
finalkey.delete()
sys.stdout.write('!')
sys.stdout.flush()
sys.stdout.write('\n')
for delete_host, delete_backup_num in to_delete:
host_backups = bm.all_backups.get(delete_host, {})
delete_backup = host_backups.get(delete_backup_num, {})
delete_files = delete_backup.get("files", [])
final_file = delete_backup.get("final_file", None)
if len(delete_files) > 0:
for file in ChargingBar(f"Deleting backup {delete_host} #{delete_backup_num}:", max=len(delete_files)).\
iter(delete_files):
if not test:
file.delete()
if options.start:
for deletehost, deletebackupnum in to_delete:
bmgr.invalidate_host_cache(deletehost)
score_iter = choose_host_to_backup(bmgr.backups_by_age,
target_count=int(options.keep) + 1)
if final_file and not test:
final_file.delete()
if start:
for delete_host, delete_backup_num in to_delete:
bm.invalidate_host_cache(delete_host)
score_iter = choose_host_to_backup(bm.backups_by_age, target_count=int(keep) + 1)
for candidate, score in score_iter:
if score > 0:
sys.stdout.write('Starting archive operation for host: '
'%s (score=%g)\n' % (candidate, score))
print(f"Starting archive operation for host: {candidate} (score={score})")
start_archive([candidate])
break
if args[0] == 'list' or options.list:
sys.stdout.write('%25s | %5s | %20s | %5s\n' % (
"Hostname", "Bkup#", "Age", "Files"))
sys.stdout.write(('-' * 72) + '\n')
for hostname, backups in bmgr.all_backups.items():
for backupnum in sorted(backups.keys()):
filecount = len(backups[backupnum]['keys'])
datestruct = backups[backupnum]['date']
if backups[backupnum]['finalized'] > 0:
inprogress = ''
else:
inprogress = '*'
timestamp = time.mktime(datestruct)
delta = int(time.time() - timestamp + time.timezone)
if delta < 3600:
prettydelta = '%i min ago' % (delta / 60)
elif delta < 86400:
prettydelta = '%i hr ago' % (delta / 3600)
else:
days = int(delta / 60 / 60 / 24)
if days == 1:
s = ''
else:
s = 's'
prettydelta = '%i day%s ago' % (days, s)
sys.stdout.write('%25s | %5i | %20s | %5i%s\n' % (
hostname, backupnum, prettydelta, filecount, inprogress))
sys.stdout.write('* == not yet finalized (Age == time of '
'last activity)\n')
if __name__ == '__main__':
def list_backups(bm: BackupManager):
print(f"{'Hostname':>25} | {'Bkup#':>5} | {'Age':>30} | {'Files':>5}")
print(("-" * 72))
for hostname, backups in bm.all_backups.items():
for backup_num in sorted(backups.keys()):
filecount = len(backups[backup_num]["files"])
date = backups[backup_num]["date"]
if backups[backup_num]["finalized"] > 0:
in_progress = ""
else:
in_progress = "*"
print(f"{hostname:>25} | {backup_num:>5} | {str(datetime.now() - date):>30} | {filecount:>5}{in_progress}")
print("* = not yet finalized (Age = time of last activity)")
def main():
parser = ArgumentParser(description="Companion maintenance script for BackupPC_archiveHost_s3. " +
"By default, it assumes the 'list' command, which displays all " +
"of the backups currently archived on B2. The 'delete' command " +
"is used to delete backups. The 'script' command produces a " +
"script that can be used to download and restore a backup.")
parser.add_argument("-l", "--list", dest="list", action="store_true",
help="List stored backups after completing operations")
subparsers = parser.add_subparsers(required=True, dest="action")
subparsers.add_parser("list")
delete_parser = subparsers.add_parser("delete")
delete_parser.add_argument("-s", "--start-backups", dest="start", action="store_true",
help="When used with --age, start backups for hosts with fewer than keep+1 backups")
delete_parser.add_argument("-k", "--keep", dest="keep", help="When used with --age, keep this many recent backups",
default=1)
delete_parser.add_argument("-t", "--test", dest="test", action="store_true",
help="Test mode; don't actually delete")
delete_parser.add_argument("-H", "--host", dest="host", help="Name of backed-up host")
delete_parser.add_argument("-b", "--backup-number", dest="backup_num", type=int, help="Backup number")
delete_parser.add_argument("-a", "--age", dest="age", help="Delete backups older than AGE days")
script_parser = subparsers.add_parser("script")
script_parser.add_argument("-H", "--host", dest="host", required=True, help="Name of backed-up host")
script_parser.add_argument("-b", "--backup-number", dest="backup_num", type=int, help="Backup number")
script_parser.add_argument("-f", "--filename", dest="filename", help="Output filename for script")
script_parser.add_argument("-x", "--expire", dest="expire", default=86400, help="Maximum age of script")
script_parser.add_argument("-u", "--unfinalized", dest="unfinalized", action="store_true",
help="Consider unfinalized backups")
args = parser.parse_args()
bm = BackupManager(secrets.access_key, secrets.shared_key)
if args.action == "script" or args.action == "delete":
if args.backup_num and not args.host:
parser.error("Must specify --host when specifying --backup-number")
if args.host:
if args.host not in bm.all_backups:
parser.error(f"No backups found for host \"{args.host}\"")
else:
if len(bm.all_backups) == 0:
parser.error("No buckets found!")
if args.action == "script":
script(parser, bm, args.host, args.backup_num, args.unfinalized, args.expire, args.filename)
elif args.action == "delete":
if not (args.age or args.host or args.backup_num):
parser.error("--age or --host and --backup-number are required")
elif args.host and not args.backup_num:
parser.error("--backup-number required with --host")
elif args.age and (args.host or args.backup_num):
parser.error("--age can't be combined with --host or --backup-number")
elif args.start and not args.age:
parser.error("--start-backups only makes sense with --age")
delete(bm, args.keep, args.host, args.backup_num, args.age, args.test, args.start)
if args.action == "list" or args.list:
list_backups(bm)
if __name__ == "__main__":
main()