Files
travel-backup-script/travel-backup-script.py
2026-03-26 18:28:38 -05:00

200 lines
7.1 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import json
import os
import re
import subprocess
import sys
from dotenv import load_dotenv
def build_repo(endpoint: str, bucket: str, prefix: str = None) -> str:
"""
Build a restic S3 repository URL in the correct format:
s3:ENDPOINT/BUCKET[/PREFIX]
"""
parts = [f"s3:{endpoint.rstrip('/')}"]
if bucket:
parts.append(bucket.strip("/"))
if prefix:
parts.append(prefix.strip("/"))
return "/".join(parts)
def load_sources(source: str = None, source_file: str = None) -> list[str]:
"""Resolve backup sources from --source or a JSON file."""
if source is not None:
return [source]
with open(source_file, "r", encoding="utf-8") as fh:
config = json.load(fh)
paths = config.get("paths")
if not isinstance(paths, list) or not paths:
raise ValueError('config file must contain a non-empty "paths" array')
if not all(isinstance(path, str) and path.strip() for path in paths):
raise ValueError('each entry in "paths" must be a non-empty string')
return paths
def print_env_status(env: dict):
"""Print redacted environment variable status."""
env_vars = ("WASABI_ENDPOINT", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "RESTIC_PASSWORD")
for key in env_vars:
if key in env and env.get(key) is not None:
print(f" {key}=***REDACTED***")
else:
print(f" {key}=NOT SET")
def run_restic(repo: str, sources: list[str], dry_run: bool = False, env: dict = None, source_file: str = None):
"""Run restic backup command."""
cmd = [
"restic",
"-r", repo,
"backup",
*sources,
]
print("Repository:", repo)
print("Command:", " ".join(cmd))
print("Environment (redacted):")
# Use provided env or fall back to current process env
if env is None:
env = os.environ.copy()
print_env_status(env)
if dry_run:
if source_file:
print("Config file:", source_file)
print("Dry-run mode: not running restic.")
return 0
# Pass the provided environment explicitly to the restic subprocess
subprocess.check_call(cmd, env=env)
return 0
def run_restic_init(repo: str, dry_run: bool = False, env: dict = None):
"""Run restic init command to initialize a new repository."""
cmd = [
"restic",
"-r", repo,
"init",
]
print("Initializing repository:", repo)
print("Command:", " ".join(cmd))
print("Environment (redacted):")
if env is None:
env = os.environ.copy()
print_env_status(env)
if dry_run:
print("Dry-run mode: not running restic.")
return 0
subprocess.check_call(cmd, env=env)
return 0
def resolve_repo(args) -> str:
"""Resolve the restic repository string from args and environment."""
# CLI --repository takes priority, then RESTIC_REPOSITORY env var
repository = getattr(args, "repository", None) or os.getenv("RESTIC_REPOSITORY")
if repository:
if not re.match(r"^s3:s3\.[a-z0-9-]+\.wasabisys\.com/.+", repository):
print(f"error: repository '{repository}' does not match expected format: s3:s3.<region>.wasabisys.com/<bucket>[/<prefix>]")
return None
return repository
bucket = getattr(args, "bucket", None) or os.getenv("WASABI_BUCKET")
if not bucket:
print("error: --bucket or WASABI_BUCKET is required when --repository / RESTIC_REPOSITORY is not provided")
return None
region = getattr(args, "region", None) or os.getenv("WASABI_REGION")
if not region:
print("error: --region or WASABI_REGION is required when --repository / RESTIC_REPOSITORY is not provided")
return None
endpoint = os.getenv("WASABI_ENDPOINT", f"s3.{region}.wasabisys.com")
# CLI --prefix takes priority, then RESTIC_PREFIX env var
prefix = getattr(args, "prefix", None) or os.getenv("RESTIC_PREFIX")
return build_repo(endpoint, bucket, prefix)
def build_minimal_env() -> dict:
"""Build a minimal env dict to pass to restic subprocesses."""
proc_env = os.environ.copy()
needed_keys = ("WASABI_ENDPOINT", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "RESTIC_PASSWORD")
minimal_env = {}
for k in needed_keys:
v = proc_env.get(k)
if v is not None:
minimal_env[k] = v
# Preserve system env vars needed by subprocesses
# PATH/HOME: find restic and resolve home directory
# SYSTEMROOT/COMSPEC: required on Windows for DNS resolution and shell access
system_keys = ("PATH", "HOME", "SYSTEMROOT", "SystemRoot", "COMSPEC")
for k in system_keys:
v = proc_env.get(k)
if v is not None:
minimal_env[k] = v
return minimal_env
def main():
# Load environment variables from .env.local (if present)
load_dotenv(".env.local")
parser = argparse.ArgumentParser(description="Backup files to Wasabi S3 with restic")
subparsers = parser.add_subparsers(dest="command", required=True)
# Shared repo arguments
repo_args = argparse.ArgumentParser(add_help=False)
repo_args.add_argument("--bucket", help="Wasabi S3 bucket name (or set WASABI_BUCKET)")
repo_args.add_argument("--prefix", help="Prefix (folder) inside bucket (or set RESTIC_PREFIX)", default=None)
repo_args.add_argument("--region", default=None, help="Wasabi region, e.g. us-west-1 (or set WASABI_REGION)")
repo_args.add_argument("--repository", help="Full restic repository string (or set RESTIC_REPOSITORY)")
repo_args.add_argument("--dry-run", action="store_true", help="Print command instead of running it")
# init subcommand
subparsers.add_parser("init", parents=[repo_args], help="Initialize a new restic repository")
# backup subcommand
backup_parser = subparsers.add_parser("backup", parents=[repo_args], help="Run a backup")
source_group = backup_parser.add_mutually_exclusive_group(required=False)
source_group.add_argument("--source", "-s", help="Path to file or directory to back up")
source_group.add_argument("--file", "-f", help='Path to a JSON config file containing {"paths": [...]} (or set FILE_PATH_CONFIG_PATH)')
args = parser.parse_args()
repo = resolve_repo(args)
if repo is None:
return 2
minimal_env = build_minimal_env()
try:
if args.command == "init":
return run_restic_init(repo, args.dry_run, env=minimal_env)
# backup command — resolve file path from CLI or env var
source_file = args.file or os.getenv("FILE_PATH_CONFIG_PATH")
if not args.source and not source_file:
print("error: --source, --file, or FILE_PATH_CONFIG_PATH is required for backup")
return 2
sources = load_sources(args.source, source_file)
return run_restic(repo, sources, args.dry_run, env=minimal_env, source_file=source_file)
except subprocess.CalledProcessError as exc:
print(f"error: restic exited with code {exc.returncode}: {exc}", file=sys.stderr)
return exc.returncode
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"error: {exc}")
return 2
if __name__ == "__main__":
sys.exit(main())