From c64ef3b95e5f3cd5985005372c6d69c37615df73 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 2 Sep 2025 00:28:28 -0500 Subject: [PATCH 1/2] starting point --- .gitignore | 1 + README.md | 62 +++++++++++++++++- requirements.txt | 1 + travel-backup-script.py | 136 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 travel-backup-script.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbd0003 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env.* \ No newline at end of file diff --git a/README.md b/README.md index 7881249..66cb487 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# Travel Backup Script +# Wasabi Restic Backup Script + +This script automates backups to a [Wasabi](https://wasabi.com/) (S3-compatible) bucket using [restic](https://restic.net/). +It supports environment-based secrets (via `.env` + [python-dotenv](https://pypi.org/project/python-dotenv/)), CLI overrides, and dry-run/verbose modes. + +--- + +## Features + +- Backup any file or directory to Wasabi S3 storage with restic. +- Secrets loaded from a `.env` file (no need to type passwords on the CLI). +- CLI arguments override `.env` and system environment variables. +- Verbose and dry-run modes for debugging. +- Environment variable redaction in output (so logs won’t leak secrets). + +--- + +## Requirements + +- Python 3.8+ +- [restic](https://restic.net/) installed and in your `PATH` +- `python-dotenv` installed: + ```bash + pip install python-dotenv + + +## Initialize the Repository (first run only) + +restic -r s3:s3.[REGION].wasabisys.com/[BUCKET_NAME]/[PREFIX] init + + +## CLI Options + +Option Description +--source, -s Source path to back up (required) +--bucket, -b Wasabi bucket name (required unless --repository used) +--endpoint, -e S3 endpoint (default: s3.wasabisys.com or from env) +--prefix, -p Path inside bucket (default: travel-backup) +--access-key Wasabi access key (overrides env/.env) +--secret-key Wasabi secret key (overrides env/.env) +--password, -P Restic password (overrides env/.env) +--repository, -r Full restic repository string (overrides bucket/endpoint/prefix) +--env-file Path to .env file (default: .env) +--dry-run Show command and env but do not execute +--verbose Show extra debug info + +## Example Dry Run +python travel-backup-backup.py --source /etc --bucket my-bucket --dry-run --verbose + + +## Run the Backup + +python backup.py --source /path/to/data --bucket my-bucket + +**Example with overrides:** +python backup.py \ + --source ~/Documents \ + --bucket my-bucket \ + --prefix laptop-backups \ + --verbose + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e338bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-dotenv \ No newline at end of file diff --git a/travel-backup-script.py b/travel-backup-script.py new file mode 100644 index 0000000..6b8b5a3 --- /dev/null +++ b/travel-backup-script.py @@ -0,0 +1,136 @@ +import argparse +import subprocess +import os +import sys +from shutil import which +from dotenv import load_dotenv + + +def build_repo(endpoint: str, bucket: str, prefix: str) -> 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 redact_env(env: dict) -> dict: + """Return a copy of env with secrets redacted.""" + redacted = env.copy() + for k in ("AWS_SECRET_ACCESS_KEY", "RESTIC_PASSWORD", "AWS_SECRET_KEY"): + if k in redacted and redacted[k]: + redacted[k] = "***REDACTED***" + return redacted + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Backup files to a Wasabi (S3) bucket using restic." + ) + parser.add_argument("--source", "-s", required=True, + help="Source path to back up (file or directory)") + parser.add_argument("--bucket", "-b", + help="Wasabi bucket name (can be omitted if --repository is used)") + parser.add_argument("--endpoint", "-e", + default=os.environ.get("WASABI_ENDPOINT", "s3.wasabisys.com"), + help="Wasabi S3 endpoint host (default: s3.wasabisys.com or WASABI_ENDPOINT env)") + parser.add_argument("--prefix", "-p", + default=os.environ.get("RESTIC_PREFIX", "travel-backup"), + help="Prefix/path inside the bucket (default: travel-backup)") + parser.add_argument("--access-key", + help="Wasabi access key (falls back to .env or AWS_ACCESS_KEY_ID env)") + parser.add_argument("--secret-key", + help="Wasabi secret key (falls back to .env or AWS_SECRET_ACCESS_KEY env)") + parser.add_argument("--password", "-P", + help="Restic repository password (falls back to .env or RESTIC_PASSWORD env)") + parser.add_argument("--repository", "-r", + help="Full restic repository string (overrides bucket+endpoint+prefix)") + parser.add_argument("--dry-run", action="store_true", + help="Print the restic command and environment without running it") + parser.add_argument("--verbose", action="store_true", help="Show more output") + parser.add_argument("--env-file", default=".env", + help="Path to .env file (default: .env)") + + args = parser.parse_args(argv) + + # Load env file (if it exists) + if os.path.exists(args.env_file): + load_dotenv(args.env_file) + + # Validate source path + src = args.source + if not os.path.exists(src): + print(f"error: source path does not exist: {src}") + return 2 + + # Build repository string + if args.repository: + repo = args.repository + else: + if not args.bucket: + print("error: --bucket is required when --repository is not provided") + return 2 + repo = build_repo(args.endpoint, args.bucket, args.prefix) + + # Prepare environment + env = os.environ.copy() + + # Apply CLI > env > .env precedence + if args.password: + env["RESTIC_PASSWORD"] = args.password + if args.access_key: + env["AWS_ACCESS_KEY_ID"] = args.access_key + if args.secret_key: + env["AWS_SECRET_ACCESS_KEY"] = args.secret_key + + # Validation: ensure critical vars exist + missing = [] + for var in ("RESTIC_PASSWORD", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"): + if not env.get(var): + missing.append(var) + if missing: + print(f"error: missing required environment variables: {', '.join(missing)}") + print(f"tip: define them in {args.env_file} or pass as CLI args") + return 2 + + # Ensure restic is installed + if which("restic") is None: + print("error: restic binary not found in PATH. Install restic and try again.") + return 3 + + cmd = ["restic", "-r", repo, "backup", src] + + if args.verbose or args.dry_run: + print("Repository:", repo) + print("Command:", " ".join(cmd)) + print("Environment (redacted):") + for k, v in redact_env(env).items(): + if k.startswith("AWS_") or k.startswith("RESTIC_") or k in ("WASABI_ENDPOINT",): + print(f" {k}={v}") + + if args.dry_run: + print("Dry-run mode: not running restic.") + return 0 + + # Run restic + try: + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + except Exception as exc: + print("error: failed to run restic:", exc) + return 4 + + if result.stdout: + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) -- 2.47.3 From d0b41fe47bfb005140b1358ab6edb49495e49b95 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 2 Sep 2025 01:14:43 -0500 Subject: [PATCH 2/2] travel backup script complete --- travel-backup-script.py | 145 +++++++++++----------------------------- 1 file changed, 38 insertions(+), 107 deletions(-) diff --git a/travel-backup-script.py b/travel-backup-script.py index 6b8b5a3..68db53b 100644 --- a/travel-backup-script.py +++ b/travel-backup-script.py @@ -1,15 +1,14 @@ +#!/usr/bin/env python3 import argparse -import subprocess import os +import subprocess import sys -from shutil import which from dotenv import load_dotenv - -def build_repo(endpoint: str, bucket: str, prefix: str) -> str: +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 + s3:ENDPOINT/BUCKET[/PREFIX] """ parts = [f"s3:{endpoint.rstrip('/')}"] if bucket: @@ -18,119 +17,51 @@ def build_repo(endpoint: str, bucket: str, prefix: str) -> str: parts.append(prefix.strip("/")) return "/".join(parts) +def run_restic(repo: str, source: str, dry_run: bool = False): + """Run restic backup command.""" + cmd = [ + "restic", + "-r", repo, + "backup", + source, + ] + print("Repository:", repo) + print("Command:", " ".join(cmd)) + print("Environment (redacted):") + for key in ("WASABI_ENDPOINT", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "RESTIC_PASSWORD"): + if key in os.environ: + print(f" {key}=***REDACTED***") + if dry_run: + print("Dry-run mode: not running restic.") + return 0 + return subprocess.call(cmd) -def redact_env(env: dict) -> dict: - """Return a copy of env with secrets redacted.""" - redacted = env.copy() - for k in ("AWS_SECRET_ACCESS_KEY", "RESTIC_PASSWORD", "AWS_SECRET_KEY"): - if k in redacted and redacted[k]: - redacted[k] = "***REDACTED***" - return redacted +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") + parser.add_argument("--source", required=True, help="Path to file or directory to back up") + parser.add_argument("--bucket", help="Wasabi S3 bucket name") + parser.add_argument("--prefix", help="Prefix (folder) inside bucket", default=None) + parser.add_argument("--region", default="us-east-1", help="Wasabi region, e.g. us-west-1") + parser.add_argument("--repository", help="Full restic repository string (overrides bucket/prefix)") + parser.add_argument("--dry-run", action="store_true", help="Print command instead of running it") + args = parser.parse_args() -def main(argv=None): - parser = argparse.ArgumentParser( - description="Backup files to a Wasabi (S3) bucket using restic." - ) - parser.add_argument("--source", "-s", required=True, - help="Source path to back up (file or directory)") - parser.add_argument("--bucket", "-b", - help="Wasabi bucket name (can be omitted if --repository is used)") - parser.add_argument("--endpoint", "-e", - default=os.environ.get("WASABI_ENDPOINT", "s3.wasabisys.com"), - help="Wasabi S3 endpoint host (default: s3.wasabisys.com or WASABI_ENDPOINT env)") - parser.add_argument("--prefix", "-p", - default=os.environ.get("RESTIC_PREFIX", "travel-backup"), - help="Prefix/path inside the bucket (default: travel-backup)") - parser.add_argument("--access-key", - help="Wasabi access key (falls back to .env or AWS_ACCESS_KEY_ID env)") - parser.add_argument("--secret-key", - help="Wasabi secret key (falls back to .env or AWS_SECRET_ACCESS_KEY env)") - parser.add_argument("--password", "-P", - help="Restic repository password (falls back to .env or RESTIC_PASSWORD env)") - parser.add_argument("--repository", "-r", - help="Full restic repository string (overrides bucket+endpoint+prefix)") - parser.add_argument("--dry-run", action="store_true", - help="Print the restic command and environment without running it") - parser.add_argument("--verbose", action="store_true", help="Show more output") - parser.add_argument("--env-file", default=".env", - help="Path to .env file (default: .env)") + # Endpoint based on region (unless already set in env) + endpoint = os.getenv("WASABI_ENDPOINT", f"s3.{args.region}.wasabisys.com") - args = parser.parse_args(argv) - - # Load env file (if it exists) - if os.path.exists(args.env_file): - load_dotenv(args.env_file) - - # Validate source path - src = args.source - if not os.path.exists(src): - print(f"error: source path does not exist: {src}") - return 2 - - # Build repository string + # Build repo string if args.repository: repo = args.repository else: if not args.bucket: print("error: --bucket is required when --repository is not provided") return 2 - repo = build_repo(args.endpoint, args.bucket, args.prefix) - - # Prepare environment - env = os.environ.copy() - - # Apply CLI > env > .env precedence - if args.password: - env["RESTIC_PASSWORD"] = args.password - if args.access_key: - env["AWS_ACCESS_KEY_ID"] = args.access_key - if args.secret_key: - env["AWS_SECRET_ACCESS_KEY"] = args.secret_key - - # Validation: ensure critical vars exist - missing = [] - for var in ("RESTIC_PASSWORD", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"): - if not env.get(var): - missing.append(var) - if missing: - print(f"error: missing required environment variables: {', '.join(missing)}") - print(f"tip: define them in {args.env_file} or pass as CLI args") - return 2 - - # Ensure restic is installed - if which("restic") is None: - print("error: restic binary not found in PATH. Install restic and try again.") - return 3 - - cmd = ["restic", "-r", repo, "backup", src] - - if args.verbose or args.dry_run: - print("Repository:", repo) - print("Command:", " ".join(cmd)) - print("Environment (redacted):") - for k, v in redact_env(env).items(): - if k.startswith("AWS_") or k.startswith("RESTIC_") or k in ("WASABI_ENDPOINT",): - print(f" {k}={v}") - - if args.dry_run: - print("Dry-run mode: not running restic.") - return 0 - - # Run restic - try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True) - except Exception as exc: - print("error: failed to run restic:", exc) - return 4 - - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) - - return result.returncode + repo = build_repo(endpoint, args.bucket, args.prefix) + return run_restic(repo, args.source, args.dry_run) if __name__ == "__main__": sys.exit(main()) -- 2.47.3