Server backup with notification (rsync + rclone + ntfy)

[[job]] # External drive backup
src = "/home/bazizi/.config/"
dst = "/mnt/seagate/.config/"
[[job]] # Dropbox backup
src = "/home/bazizi/.config"
dst = "dbox:.config/"
#!/usr/bin/env python3
import argparse
import os
import subprocess
import tomllib
from typing import List, Dict
import requests
import docker
NTFY_URL = "https://"
RCLONE_REMOTES = ("dbox:",)
EXCLUDES_OPTION = "--exclude-from=/path/to/rsync-excludes.txt"
CONFIG_FILE = "/path/to/backup_jobs.toml"
def load_jobs(filepath: str) -> List[Dict[str, str]]:
"""Load backup locations from an external TOML file."""
if not os.path.exists(filepath):
print(f"❌ Configuration file not found: {filepath}")
return []
try:
with open(filepath, "rb") as f:
data = tomllib.load(f)
# Expects an array of tables under the key [[job]]
return data.get("job", [])
except Exception as e:
print(f"❌ Failed to parse TOML configuration file: {e}")
return []
def run_job(src: str, dst: str, dry_run: bool = False) -> tuple[bool, str]:
if any(dst.startswith(r) for r in RCLONE_REMOTES):
cmd = ["rclone", "copy", "-v", EXCLUDES_OPTION, src, dst]
else:
cmd = [
"rsync", "-avz", "--mkpath", "--no-links",
EXCLUDES_OPTION,
src, dst,
]
cmd_str = " ".join(cmd)
print("Running:", cmd_str)
if dry_run:
return True, f"[DRY RUN] Would execute: {cmd_str}"
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return True, result.stdout + result.stderr
except subprocess.CalledProcessError as e:
return False, (e.stdout or "") + (e.stderr or "") + f"\nExit code: {e.returncode}"
def notify(title: str, body: str, dry_run: bool = False) -> None:
print(f"\n--- NOTIFICATION ---\nTitle: {title}\n{body}\n--------------------")
if dry_run:
print("ℹ️ [DRY RUN] Notification skipped.")
return
try:
requests.post(
NTFY_URL,
data=f"**From/To job**\n\n```\n{body}\n```",
headers={"Title": title, "Markdown": "yes"},
timeout=10
)
except Exception as e:
print(f"⚠️ Failed to send ntfy notification: {e}")
def main() -> None:
parser = argparse.ArgumentParser(description="Automated Docker-aware backup script.")
parser.add_argument("--dry-run", action="store_true", help="Simulate backup jobs and docker actions without changing anything.")
args = parser.parse_args()
locations = load_jobs(CONFIG_FILE)
if not locations:
print("⚠️ No backup locations loaded. Exiting.")
return
try:
client = docker.from_env()
except Exception as e:
print(f"❌ Error connecting to Docker daemon. Is Docker running? Details: {e}")
return
try:
running_containers = client.containers.list(filters={"status": "running"})
except Exception as e:
print(f"❌ Failed to list containers: {e}")
return
container_records = []
print(f"📋 Found {len(running_containers)} running container(s):")
for container in running_containers:
print(f" • {container.name} ({container.id[:12]})")
container_records.append({
"id": container.id,
"name": container.name,
"container_obj": container
})
prefix_msg = "🔍 [DRY RUN] " if args.dry_run else ""
docker_output = f"{prefix_msg}🛑 Stopping containers..."
# Stop containers
for item in container_records:
try:
docker_output += f"\n Stopping {item['name']}..."
if not args.dry_run:
item['container_obj'].stop(timeout=10)
except Exception as e:
docker_output += f" ❌ Failed: {e}"
try:
# Run backup jobs
for config in locations:
ok, output = run_job(config["src"], config["dst"], dry_run=args.dry_run)
status = "OK" if ok else "FAILED"
print(output)
docker_output += f"\nBackup {status}: {config['src']} -> {config['dst']}"
finally:
# Guaranteed Restart of containers
docker_output += f"\n\n{prefix_msg}🔄 Restarting containers..."
for item in container_records:
try:
docker_output += f"\n Starting {item['name']}..."
if not args.dry_run:
item['container_obj'].start()
except Exception as e:
docker_output += f" ❌ Failed: {e}"
docker_output += f"\n\n{prefix_msg}✅ Process completed successfully!"
notify("Backup Summary", docker_output, dry_run=args.dry_run)
if __name__ == "__main__":
main()
参与讨论