Files
boxtools/repo_sync.py
T

140 lines
3.8 KiB
Python

#!/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 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/./{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)
dorestore = f"{REMOTE_DIR}/do.restore"
if ssh(f"ls {dorestore}") is None:
log("no restore needed")
else:
if restore():
log("restore done")
ssh(f"rm {dorestore}")
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()
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()