mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-11-08 06:08:05 +00:00
- Replace Bash pull-specific-host.sh with Python pull-specific-host.py (argparse, identical logic) - Update role vars and runner template to call python script - Add __init__.py files for test discovery/imports - Add unittest: tests/unit/roles/svc-bkp-rmt-2-loc/files/test_pull_specific_host.py (mocks subprocess/os/time; covers success, no types, find-fail, retry-exhaustion) - Backup provider SSH wrapper: align allowed ls path (backup-docker-to-local) - Split user role tasks: 01_core (sudoers), 02_permissions_ssh (SSH keys + wrapper), 03_permissions_folders (ownership + default ACLs + depth-limited chown/chmod) - Ensure default ACLs grant rwx to 'backup' and none to group/other; keep sudo rsync working Ref: ChatGPT discussion (2025-10-14) — https://chatgpt.com/share/68ee920a-9b98-800f-8806-ddcfe0255149
133 lines
4.5 KiB
Python
133 lines
4.5 KiB
Python
#!/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()
|