feat: add borg-wrapper

This commit is contained in:
bmondream committed 2026-09-12 20:44:24 +02:00
1 parent a7f9527344
commit 4df9c0116e
2 files changed
+208

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
import argparse
import json
import os
import subprocess
"""
Very primitive and unpolished borg wrapper for certain actions.
It relies on a JSON config from which it reads borg vault locations and passwords.
"""
def get_destination_host(destination_hosts, backup_destination):
for entry in destination_hosts:
if entry['alias'] == backup_destination:
return entry['host']
raise Exception(f"Destination host '{backup_destination}' is missing its configuration.")
def sync_backup(args):
operation = args.operation
print("Opening password file ...")
# Read Borg archive passwords from JSON file using sudo
password_file_path = '/root/borg_passwords.json'
password_command = f'sudo cat {password_file_path}'
password_data = os.popen(password_command).read().strip()
# Parse JSON data
passwords = json.loads(password_data)
if operation == 'create_all':
for entry in passwords['archives']:
password = entry['password']
destination_host_alias = entry['destination_host_alias']
destination_host = get_destination_host(passwords['destination_hosts'], destination_host_alias)
local_archive_root_path = entry['local_archive_root_path']
remote_archive_path = entry['remote_archive_path']
local_backup_source_dir = entry['local_backup_source_dir']
if args.backup_destination is not None and args.backup_destination != destination_host_alias:
print(f'skipping archive {entry['archive_name']} of destination {destination_host_alias}')
continue
if not args.skip_missing_source and not os.path.exists(local_archive_root_path):
raise RuntimeError(f"local archive root path '{local_archive_root_path}' doesn't exist")
elif args.skip_missing_source:
print(f"local archive root path is missing, skipping")
continue
# Set up Borg command with operation as the first argument
borg_env_var = f'export BORG_PASSPHRASE="{password}"'
borg_command = f'{borg_env_var}; cd {local_archive_root_path} && borg create --rsh \'ssh -i ~/.ssh/id_ed25519\''
if args.dry_run is True:
print("dry run enabled")
dry_run_or_stats = '--dry-run' if args.dry_run is True else '--stats'
if args.ignore_inode:
borg_command += ' --files-cache ctime,size' # ignore inode (useful for external disks)
borg_command += f' --iec --progress {dry_run_or_stats} -v --compression none --checkpoint-interval=900'
borg_command += f' {destination_host}{remote_archive_path}::' + '{utcnow}Z ' + f'{local_backup_source_dir}'
# Execute Borg command
print(f'[CMD] {";".join(borg_command.split(";")[1:])}')
retcode = subprocess.call(borg_command, shell=True)
if retcode != 0:
raise RuntimeError("exited with non-zero return code")
elif operation == 'prune_all':
for entry in passwords['archives']:
password = entry['password']
destination_host = get_destination_host(passwords['destination_hosts'], entry['destination_host_alias'])
local_archive_root_path = entry['local_archive_root_path']
remote_archive_path = entry['remote_archive_path']
#local_backup_source_dir = entry['local_backup_source_dir']
if not args.skip_missing_source and not os.path.exists(local_archive_root_path):
raise RuntimeError(f"local archive root path '{local_archive_root_path}' doesn't exist")
elif args.skip_missing_source:
print(f"local archive root path is missing, skipping")
continue
# Set up Borg command with operation as the first argument
borg_env_var = f'export BORG_PASSPHRASE="{password}"'
borg_command = f'{borg_env_var}; cd {local_archive_root_path} && borg prune --rsh \'ssh -i ~/.ssh/id_ed25519\''
if args.dry_run is True:
print("dry run enabled")
dry_run_or_stats = '--dry-run' if args.dry_run is True else '--stats'
borg_command += f' --iec --progress {dry_run_or_stats} -v'
borg_command += f' --list --keep-last 1 {destination_host}{remote_archive_path}'
# Execute Borg command
print(f'[CMD] {";".join(borg_command.split(";")[1:])}')
retcode = subprocess.call(borg_command, shell=True)
if retcode != 0:
raise RuntimeError("exited with non-zero return code")
elif operation == 'compact_all':
for entry in passwords['archives']:
password = entry['password']
destination_host = get_destination_host(passwords['destination_hosts'], entry['destination_host_alias'])
local_archive_root_path = entry['local_archive_root_path']
remote_archive_path = entry['remote_archive_path']
#local_backup_source_dir = entry['local_backup_source_dir']
if not args.skip_missing_source and not os.path.exists(local_archive_root_path):
raise RuntimeError(f"local archive root path '{local_archive_root_path}' doesn't exist")
elif args.skip_missing_source:
print(f"local archive root path is missing, skipping")
continue
# Set up Borg command with operation as the first argument
borg_env_var = f'export BORG_PASSPHRASE="{password}"'
borg_command = f'{borg_env_var}; cd {local_archive_root_path} && borg compact --rsh \'ssh -i ~/.ssh/id_ed25519\''
borg_command += f' --iec --progress -v {destination_host}{remote_archive_path}'
# Execute Borg command
print(f'[CMD] {";".join(borg_command.split(";")[1:])}')
retcode = subprocess.call(borg_command, shell=True)
if retcode != 0:
raise RuntimeError("exited with non-zero return code")
else:
found = False
for entry in passwords['archives']:
if entry['archive_name'] == args.archive_name and entry['destination_host_alias'] == args.backup_destination:
found = True
password = entry['password']
destination_host = get_destination_host(passwords['destination_hosts'], args.backup_destination)
local_archive_root_path = entry['local_archive_root_path']
remote_archive_path = entry['remote_archive_path']
local_backup_source_dir = entry['local_backup_source_dir']
if not args.skip_missing_source and not os.path.exists(local_archive_root_path):
raise RuntimeError(f"local archive root path '{local_archive_root_path}' doesn't exist")
elif args.skip_missing_source:
print(f"local archive root path is missing, skipping")
continue
# Set up Borg command with operation as the first argument
borg_env_var = f'export BORG_PASSPHRASE="{password}"'
borg_command = f'{borg_env_var}; cd {local_archive_root_path} && borg {operation} --rsh \'ssh -i ~/.ssh/id_ed25519\''
if operation == 'create':
dry_run_or_stats = '--dry-run' if args.dry_run is True else '--stats'
if args.ignore_inode:
borg_command += ' --files-cache ctime,size' # ignore inode (useful for external disks)
if not args.skip_list:
borg_command += ' --list'
borg_command += f' --iec --progress {dry_run_or_stats} -v --compression none --checkpoint-interval=900'
borg_command += f' {destination_host}{remote_archive_path}::' + '{utcnow}Z ' + f'{local_backup_source_dir}'
elif operation == 'list':
borg_command += f' {destination_host}{remote_archive_path}'
elif operation == 'info':
borg_command += f' --iec {destination_host}{remote_archive_path}'
elif operation == 'prune':
borg_command += f' --list --keep-last 1 {destination_host}{remote_archive_path}'
elif operation == 'compact':
borg_command += f' --iec --progress {destination_host}{remote_archive_path}'
# elif operation == 'delete':
else:
print(f"Invalid operation '{operation}'")
return
# Execute Borg command
print(f'[CMD] {";".join(borg_command.split(";")[1:])}')
os.system(borg_command)
break
if not found:
print(f"Couldn't find a pair of archive '{args.archive_name}' and destination '{args.backup_destination}'.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Sync backups using Borg via CLI")
parser.add_argument("operation", type=str, help="Borg operation (create, list, prune, info, compact, create_all, prune_all, compact_all)")
parser.add_argument("-a", "--archive-name", type=str, help="Name of the Borg archive", required=False)
parser.add_argument("-d", "--backup-destination", type=str, help="Destination for the backup", required=False)
parser.add_argument("--dry-run", type=bool, help="If specified, shows only what would happen", required=False)
parser.add_argument("--skip-missing-source", action=argparse.BooleanOptionalAction, help="Skips syncing a missing archive instead of crashing", required=False)
parser.add_argument("--ignore-inode", action=argparse.BooleanOptionalAction, help="Ignores inodes when creating a backup", required=False)
parser.add_argument("--skip-list", action=argparse.BooleanOptionalAction, help="Skips printing list of synced files", required=False)
arguments = parser.parse_args()
print(f'debug: {arguments.operation}')
if arguments.operation in ["create_all", "prune_all", "compact_all"]:
if arguments.archive_name is not None:
raise RuntimeError(f"Argument -a cannot be used with operation '{arguments.operation}'.")
else:
if arguments.archive_name is None or arguments.backup_destination is None:
raise RuntimeError(f"Arguments -a and -d must be used with operation '{arguments.operation}'.")
sync_backup(arguments)