#!/usr/bin/env python3 import argparse import os import subprocess import time import sys def run_command(command, capture_output=True, check=False, shell=True): """Run a shell command and return its output as string.""" try: result = subprocess.run( command, capture_output=capture_output, shell=shell, text=True, check=check ) return result.stdout.strip() except subprocess.CalledProcessError as e: if capture_output: print(e.stdout) print(e.stderr) raise def pull_backups(hostname: str): print(f"pulling backups from: {hostname}") errors = 0 print("loading meta data...") remote_host = f"backup@{hostname}" print(f"host address: {remote_host}") remote_machine_id = run_command(f'ssh "{remote_host}" sha256sum /etc/machine-id')[:64] print(f"remote machine id: {remote_machine_id}") general_backup_machine_dir = f"/Backups/{remote_machine_id}/" print(f"backup dir: {general_backup_machine_dir}") try: remote_backup_types = run_command( f'ssh "{remote_host}" "find {general_backup_machine_dir} -maxdepth 1 -type d -execdir basename {{}} ;"' ).splitlines() print(f"backup types: {' '.join(remote_backup_types)}") except subprocess.CalledProcessError: sys.exit(1) for backup_type in remote_backup_types: if backup_type == remote_machine_id: continue print(f"backup type: {backup_type}") general_backup_type_dir = f"{general_backup_machine_dir}{backup_type}/" general_versions_dir = general_backup_type_dir # local previous version try: local_previous_version_dir = run_command(f"ls -d {general_versions_dir}* | tail -1") except subprocess.CalledProcessError: local_previous_version_dir = "" print(f"last local backup: {local_previous_version_dir}") # remote versions remote_backup_versions = run_command( f'ssh "{remote_host}" "ls -d /Backups/{remote_machine_id}/backup-docker-to-local/*"' ).splitlines() print(f"remote backup versions: {' '.join(remote_backup_versions)}") remote_last_backup_dir = remote_backup_versions[-1] if remote_backup_versions else "" print(f"last remote backup: {remote_last_backup_dir}") remote_source_path = f"{remote_host}:{remote_last_backup_dir}/" print(f"source path: {remote_source_path}") local_backup_destination_path = remote_last_backup_dir print(f"backup destination: {local_backup_destination_path}") print("creating local backup destination folder...") os.makedirs(local_backup_destination_path, exist_ok=True) rsync_command = ( f'rsync -abP --delete --delete-excluded --rsync-path="sudo rsync" ' f'--link-dest="{local_previous_version_dir}" "{remote_source_path}" "{local_backup_destination_path}"' ) print("starting backup...") print(f"executing: {rsync_command}") retry_count = 0 max_retries = 12 retry_delay = 300 # 5 minutes last_retry_start = 0 max_retry_duration = 43200 # 12 hours rsync_exit_code = 1 while retry_count < max_retries: print(f"Retry attempt: {retry_count + 1}") if retry_count > 0: current_time = int(time.time()) last_retry_duration = current_time - last_retry_start if last_retry_duration >= max_retry_duration: print("Last retry took more than 12 hours, increasing max retries to 12.") max_retries = 12 last_retry_start = int(time.time()) rsync_exit_code = os.system(rsync_command) if rsync_exit_code == 0: break retry_count += 1 time.sleep(retry_delay) if rsync_exit_code != 0: print(f"Error: rsync failed after {max_retries} attempts") errors += 1 sys.exit(errors) def main(): parser = argparse.ArgumentParser( description="Pull backups from a remote backup host via rsync." ) parser.add_argument( "hostname", help="Hostname from which backup should be pulled" ) args = parser.parse_args() pull_backups(args.hostname) if __name__ == "__main__": main()