140 lines
3.8 KiB
Python
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()
|