#!/usr/bin/env python3 import re import subprocess import socket import sys import threading import time from pathlib import Path HOME = Path.home() DIRS = ["frappy", "boxtools", "boxweb"] # relative to $HOME CENTRAL = "l_samenv@linse-c" BASE = "boxes/repos" NAME = socket.gethostname() REMOTE_DIR = f"{BASE}/{NAME}" RETRY_DELAY = 60.0 # seconds before retrying after a backup failure EXCLUDE_DIRS = ["__pycache__", ".venv", ".mypy_cache"] # way may exclude .git for size and performance reasons EXCLUDE_FILES = ["*.pyc", "*.swp", "*~"] INOTIFY_EXCLUDE = ( r"(^|/)(" + "|".join(re.escape(d) for d in EXCLUDE_DIRS) + r")(/|$)" r"|\.(pyc|swp)$|~$" ) SSH_OPTS = "ssh -o BatchMode=yes -o ConnectTimeout=10" changed = threading.Event() last_event = 0.0 def log(msg: str) -> None: print(msg, flush=True) def get_dirs(): return [v for v in DIRS if (HOME / v).is_dir()] def ssh(remote_cmd: str) -> subprocess.CompletedProcess: log(f'> {remote_cmd}') try: return subprocess.check_output( SSH_OPTS.split() + [CENTRAL, remote_cmd], text=True) except subprocess.CalledProcessError as e: log(f'error: {e.output}') return None # ---------- rsync in both directions ---------- def exclude_args() -> list[str]: return [*[f"--exclude={d}/" for d in EXCLUDE_DIRS], *[f"--exclude={p}" for p in EXCLUDE_FILES]] def backup() -> bool: cmd = ["rsync", "-aR", '--delete', *exclude_args(), "-e", SSH_OPTS, *get_dirs(), f"{CENTRAL}:{REMOTE_DIR}/backup/"] return subprocess.run(cmd, cwd=HOME).returncode == 0 def make_target_dir(): return subprocess.run(['ssh', CENTRAL, 'mkdir', '-p', REMOTE_DIR]).returncode == 0 def restore() -> bool: # "/./" marks where the relative path starts, so with -R each subtree # lands under $HOME with its original relative path cmd = ["rsync", "-aR", *exclude_args(), "-e", SSH_OPTS, *[f"{CENTRAL}:{REMOTE_DIR}/restore-running/./{d}" for d in get_dirs()], f"{HOME}/"] return subprocess.run(cmd).returncode == 0 def cmd_restore(): for _ in range(60): try: socket.gethostbyname(CENTRAL.split('@')[-1]) break except Exception as e: log(repr(e)) time.sleep(1) if ssh(f"ls {REMOTE_DIR}/restore") is None: log("no restore needed") else: ssh(f"mv {REMOTE_DIR}/restore {REMOTE_DIR}/restore-running") if restore(): log("restore done") ssh(f"mv {REMOTE_DIR}/restore-running {REMOTE_DIR}/restore-done") return True else: log("restore failed") # ---------- watcher ---------- def reader(proc: subprocess.Popen) -> None: global last_event for _ in proc.stdout: last_event = time.monotonic() changed.set() changed.set() # EOF: inotifywait exited, wake the main loop def cmd_backup() -> None: """Long-running: watch and back up""" watcher = subprocess.Popen( ["inotifywait", "-m", "-r", "-q", "-e", "close_write,create,delete,move,attrib", "--exclude", INOTIFY_EXCLUDE, "--format", "%w%f", *get_dirs()], cwd=HOME, stdout=subprocess.PIPE, text=True, ) threading.Thread(target=reader, args=(watcher,), daemon=True).start() make_target_dir() try: while True: if watcher.poll() is not None: sys.exit("inotifywait exited, letting systemd restart us") changed.clear() if backup(): log('backup done') ssh(f'touch {REMOTE_DIR}/backup.done') changed.wait() else: log('backup failed') changed.wait(RETRY_DELAY) finally: watcher.terminate() def main() -> None: args = sys.argv[1:] if args == ["restore"]: cmd_restore() elif args == ["backup"]: cmd_backup() else: sys.exit(f"usage: {sys.argv[0]} restore [timeout_s] | backup") if __name__ == "__main__": main()