import fcntl import logging import os import os.path import subprocess import datetime import statistics class StepResult: success = None output = None def __init__(self, success = True, output = ''): self.success = success self.output = output class BackupResult: target_results = None par2_result = None def __init__(self): self.target_results = {} self.par2_result = StepResult() class BackupStats: def __init__(self, fname): def parse_line(line): date, size = line.strip().split() return datetime.datetime.fromisoformat(date), float(size) with open(fname, 'r') as f: self.entries = dict(map(parse_line, f)) self.dist = statistics.NormalDist.from_samples(self.entries.values()) class Repo: """ A single Bup repository into which the data will be backed up, plus a separate directory for extra runtime data. :param str bup_dir: path to the bup repository, defaults to BUP_DIR or ~/.bup :param str data_dir: path to the directory for storing the runtime data, defaults to ~/.local/var/lbup """ bup_dir = None data_dir = None lock_name = 'lock' sizestat_name = 'size' _logger = None def __init__(self, bup_dir = None, data_dir = None, logger = None): if bup_dir is None: if 'BUP_DIR' in os.environ: bup_dir = os.environ['BUP_DIR'] else: bup_dir = os.path.expanduser('~/.bup') if data_dir is None: data_dir = os.path.expanduser('~/.local/var/lbup/') if logger is None: self._logger = logging.getLogger('%s.%s' % (self.__class__.__name__, bup_dir)) else: self._logger = logger # create the data dir, if it does not already exist os.makedirs(data_dir, 0o700, exist_ok = True) self.bup_dir = bup_dir self.data_dir = data_dir def _bup_repo_size(self): ret = subprocess.run(['du', '--summarize', '--bytes', '--dereference-args', '--exclude=midx*', '--exclude=bup.bloom', self.bup_dir], capture_output = True, check = True) return int(ret.stdout.split()[0].strip()) def backup(self, tgts, *, gen_par2 = True, dry_run = False): """ Backup the supplied targets. :param list of Target tgts: List of targets to back up. :param bool gen_par2: Whether to generate par2 recovery information after the backup concludes' """ with open(os.path.join(self.data_dir, self.lock_name), 'w') as lockfile: result = BackupResult() self._logger.debug('Acquiring repository lock') fcntl.lockf(lockfile, fcntl.LOCK_EX) try: for tgt in tgts: # make sure the per-target data dir exists data_dir = os.path.join(self.data_dir, tgt.name) os.makedirs(data_dir, 0o700, exist_ok = True) # measure the pre-backup size size_pre = self._bup_repo_size() self._logger.info('Backing up %s...' % tgt.name) try: res = tgt.save(dry_run) except Exception as e: self._logger.exception('Exception backing up %s: %s' % (tgt.name, str(e))) res = StepResult(False, str(e).encode('utf-8')) else: self._logger.info('Backing up %s done' % tgt.name) # measure the post-backup size size_post = self._bup_repo_size() backup_time = datetime.datetime.now(datetime.timezone.utc) if not dry_run: with open(os.path.join(data_dir, self.sizestat_name), 'a') as f: f.write('%s %d\n' % (backup_time.isoformat(timespec = 'seconds'), size_post - size_pre)) result.target_results[tgt.name] = res if gen_par2: self._logger.info('Generating par2 files...') res = subprocess.run(['bup', 'fsck', '-g'], capture_output = True) self._logger.info('Generating par2 files done') result.par2_result = StepResult(res.returncode == 0, res.stderr + res.stdout) finally: self._logger.debug('Releasing repository lock') fcntl.lockf(lockfile, fcntl.LOCK_UN) self._logger.debug('Backup finished') return result def read_stats(self, tgt_name): filename = os.path.join(self.data_dir, tgt_name, self.sizestat_name) try: return BackupStats(filename) except statistics.StatisticsError: self._logger.warning('Could not read statistics file: %s', filename) return None