diff --git a/bin/rsync-backup b/bin/rsync-backup new file mode 120000 index 0000000..519a7c3 --- /dev/null +++ b/bin/rsync-backup @@ -0,0 +1 @@ +../files/rsync-backup \ No newline at end of file diff --git a/files/rsync-backup b/files/rsync-backup new file mode 100755 index 0000000..408c55e --- /dev/null +++ b/files/rsync-backup @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +import datetime +import subprocess +import os +import sys +import shlex +from argparse import ArgumentParser + +VERSION = "1.0" + + +class RSB: + def __init__(self, options): + self.base_folder = options.base_folder + self.backup_source = options.backup_source + self.rsync_args = options.rsync_args + self.delete_older = options.del_older + self.keep_n = options.keep_n + self.delete_disk_percentage = options.del_used + + if not self.backup_source.endswith("/"): + self.backup_source += "/" + self.current = os.path.join(self.base_folder, "current") + self.folder_format = "backup-%Y%m%d-%H%M%S" + os.makedirs(self.current, exist_ok=True) + self.clean_old() + self.make_backup() + self.make_hardlinks() + + def diskused(self): + """in percents""" + pcent = subprocess.check_output( + ["df", "--output=pcent", self.base_folder] + ).decode("utf8") + try: + pcent = int("".join([x for x in pcent if x.isdigit()])) + except Exception as e: + return 0 + return pcent + + def clean_old(self): + print("Cleaning old backups") + + now = datetime.datetime.now() + dirs = [ + d + for d in sorted(os.listdir(self.base_folder)) + if d.startswith("backup-") + and os.path.isdir(os.path.join(self.base_folder, d)) + ] + for i, d in enumerate(dirs): + if self.delete_older: + dir_date = datetime.datetime.strptime(d, self.folder_format) + dir_age = now - dir_date + is_old = dir_age.days > self.delete_older + else: + is_old = False + if self.delete_disk_percentage: + is_disk_full = self.diskused() > self.delete_disk_percentage + else: + is_disk_full = False + if self.keep_n: + not_kept = len(dirs) - i + 1 > self.keep_n + else: + not_kept = False + + print( + "{}: {} -- old: {}, disk full: {}, keep n: {}".format( + i, d, is_old, is_disk_full, not_kept + ) + ) + if any((is_old, is_disk_full, not_kept)): + print("Deleting {}".format(d)) + cmd = ["rm", "-r", os.path.join(self.base_folder, d)] + subprocess.call(cmd, shell=False) + + def make_hardlinks(self): + now = datetime.datetime.now().strftime(self.folder_format) + print("Creating new snapshot: {}".format(now)) + tgt_dir = os.path.join(self.base_folder, now) + if os.path.exists(tgt_dir): + raise FileExistsError("Folder {} already exists".format(tgt_dir)) + + subprocess.call(["cp", "-la", self.current, tgt_dir], shell=False) + + def make_backup(self): + print("Backing up") + command = [ + "rsync", + *shlex.split(self.rsync_args), + self.backup_source, + self.current, + ] + print(command) + subprocess.call(command, shell=False) + + +def get_opts(): + + parser = ArgumentParser( + description="Minimal incrementive backup utility using rsync and hard links.", + ) + parser.add_argument( + "--del-older", + action="store", + type=int, + dest="del_older", + default=None, + help="Delete old backups older than N days", + ) + parser.add_argument( + "--del-disk-used", + action="store", + type=float, + dest="del_used", + default=99, + help="Delete old backups if disk is fuller than P percentage", + ) + parser.add_argument( + "--keep-number", + action="store", + type=int, + dest="keep_n", + default=None, + help="Keep at most N old backups", + ) + + parser.add_argument( + "--rsync-args", + type=str, + dest="rsync_args", + default="-aP --del", + help="Rsync arguments. Add excludes here (example '-vxP --exclude folder/'). Defaults: '%(default)s'", + ) + parser.add_argument("--version", action="version", version=VERSION) + parser.add_argument( + "backup_source", action="store", help="Source URL to backup with rsync" + ) + parser.add_argument("base_folder", action="store", help="Local backup folder") + options = parser.parse_args() + return options + + +if __name__ == "__main__": + opts = get_opts() + RSB(opts)