#!/usr/bin/env python3 import collections import datetime import email.utils import os import platform import subprocess import sys import time # You *need* to override these... HOSTS = ('mynas.lan', 'mynas.external.tld') REPO = '/srv/borg/myhost' BORG_PASSPHRASE = 'A real good one, some 42 chars or whatever' # Modify these at will... PING_LIMIT = 30 SHARES = ('/etc', '/home') EXCLUDES = ( '-e', '**/nobackup', '-e', '/home/*/.cache', '-e', '/home/*/.npm', '-e', '/home/*/.mozilla', '-e', '/home/*/.steam', '-e', '/home/*/Downloads', ) # Remember to make borg_config.py with HOSTS, REPO and BORG_PASSPHRASE set. from borg_config import * # A bit of a hack. CLOCK_BOOTTIME exists as of Linux 2.6.39, so the system call # will succeed even though the symbolic value isn't defined until python >=3.7 timer_id = getattr(time, 'CLOCK_BOOTTIME', 7) def setup_env(): os.environ['BORG_PASSPHRASE'] = BORG_PASSPHRASE os.environ['BORG_RELOCATED_REPO_ACCESS_IS_OK'] = 'yes' _log_lines = collections.deque(maxlen=10) def log(*args, **kwargs): _log_lines.append(' '.join(args)) kwargs.setdefault('file', sys.stderr) kwargs.setdefault('flush', True) print(*args, **kwargs) def run(*cmd): return subprocess.check_call(cmd) def sleep_for_mins(n_mins): now = time.clock_gettime(timer_id) then = now + (n_mins * 60) then_text = (datetime.datetime.now() + datetime.timedelta(minutes=n_mins) ).isoformat() log('Waiting for %d minutes (until >= ~%s)' % (n_mins, then_text)) while now < then: time.sleep(60) now = time.clock_gettime(timer_id) def backup(host): run('borg', 'create', '--stats', *EXCLUDES, '--exclude-caches', '--exclude-if-present', '.nobackup', '--one-file-system', '--checkpoint-interval', '300', '--compression', 'zstd,1', '%s:%s::{now}' % (host, REPO), *SHARES) def prune(host): run('borg', 'prune', '--stats', '-d', '7', '-w', '4', '-m', '8', '-y', '3', '%s:%s' % (host, REPO)) def check(host): run('borg', 'check', '%s:%s' % (host, REPO)) def get_host(): for host in HOSTS: log('Pinging ' + host) ping = trace_ping(host) if ping is None: log('No ping result for backup host ' + host) elif ping > PING_LIMIT: log('Ping %.1f for %s exceeds limit of %d' % (ping, host, PING_LIMIT)) else: log('Successful ping at %.1f ms against %s' % (ping, host)) return host def try_op(op): host = get_host() while not host: sleep_for_mins(5) host = get_host() log('Performing %s against %s' % (op.__name__, host)) try: op(host) except subprocess.CalledProcessError as err: log('Error(s) during %s, exit code %d' % (op.__name__, err.returncode)) sendmail = subprocess.Popen(['/usr/sbin/sendmail', '-ti'], stdin=subprocess.PIPE) mail = '''To: root From: borg Subject: Error during borg %(op)s on %(hostname)s Date: %(date)s One or more error occurred running %(op)s against host %(host)s, check journal on %(hostname)s. Last 10 log entries below. %(log)s''' % { 'op': op.__name__, 'hostname': platform.node(), 'date': email.utils.formatdate(localtime=True), 'host': host, 'log': '\n'.join(_log_lines), } sendmail.communicate(mail.encode('utf-8')) sendmail.wait() sleep_for_mins(5) else: log('Successful ' + op.__name__) # 12 hours if op == backup: sleep_for_mins(12 * 60) else: sleep_for_mins(5) def trace_ping(host): try: out = subprocess.check_output(['/usr/bin/mtr', '-rn', host]) except subprocess.CalledProcessError: return None for line in reversed(out.splitlines()): parts = line.split() if parts[1] != b'???': return float(parts[5]) return None def main(): log('Starting backup runner') counter = 0 while True: # At startup, and every 11 runs if (counter % 11) == 0: try_op(check) # After first backup, then every 5 runs if (counter % 5) == 1: try_op(prune) try_op(backup) counter += 1 if __name__ == '__main__': setup_env() if len(sys.argv) > 1: op = sys.argv[1] if not op in ('backup', 'check', 'prune') or len(sys.argv) != 3: print('Usage: %s [{backup,check,prune} host]' % sys.argv[0]) else: locals()[op](sys.argv[2]) else: main()