#!/usr/bin/env python3 import datetime import subprocess import os import sys import shlex from argparse import ArgumentParser VERSION = "1.1" 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() success = self.make_backup() if success: self.make_hardlinks() else: print("Program exited with errors") sys.exit(1) 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 backup") if self.delete_older: print("Keeping newer than {:} days".format(self.delete_older)) if self.keep_n: print("Keep at most {:} backups".format(self.keep_n)) if self.delete_disk_percentage: print("Keep until disk is {:}% full".format(self.delete_disk_percentage)) 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) exitcode = subprocess.call(command, shell=False) success = exitcode in (0, 23, 24, 25) return success 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)