From 5762754ed7ed401ad3100ff8457dbcb5693ada8d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 3 Jul 2025 11:59:40 +0200 Subject: [PATCH] Added restore_backup.py restore_postgres_databases.py --- Todo.md | 2 + restore_backup.py | 170 ++++++++++++++++++++++++++++++++++ restore_postgres_databases.py | 83 +++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 Todo.md create mode 100644 restore_backup.py create mode 100644 restore_postgres_databases.py diff --git a/Todo.md b/Todo.md new file mode 100644 index 0000000..3e2d828 --- /dev/null +++ b/Todo.md @@ -0,0 +1,2 @@ +# Todo +- Verify that restore backup is correct implemented \ No newline at end of file diff --git a/restore_backup.py b/restore_backup.py new file mode 100644 index 0000000..2eee608 --- /dev/null +++ b/restore_backup.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# @todo Not tested yet. Needs to be tested +""" +restore_backup.py + +A script to recover Docker volumes and database dumps from local backups. +Supports an --empty flag to clear the database objects before import (drops all tables/functions etc.). +""" +import argparse +import os +import sys +import subprocess + + +def run_command(cmd, capture_output=False, input=None, **kwargs): + """Run a subprocess command and handle errors.""" + try: + result = subprocess.run(cmd, check=True, capture_output=capture_output, input=input, **kwargs) + return result + except subprocess.CalledProcessError as e: + print(f"ERROR: Command '{' '.join(cmd)}' failed with exit code {e.returncode}") + if e.stdout: + print(e.stdout.decode()) + if e.stderr: + print(e.stderr.decode()) + sys.exit(1) + + +def recover_postgres(container, password, db_name, user, backup_sql, empty=False): + print("Recovering PostgreSQL dump...") + os.environ['PGPASSWORD'] = password + if empty: + print("Dropping existing PostgreSQL objects...") + # Drop all tables, views, sequences, functions in public schema + drop_sql = """ +DO $$ DECLARE r RECORD; +BEGIN + FOR r IN ( + SELECT table_name AS name, 'TABLE' AS type FROM information_schema.tables WHERE table_schema='public' + UNION ALL + SELECT routine_name AS name, 'FUNCTION' AS type FROM information_schema.routines WHERE specific_schema='public' + UNION ALL + SELECT sequence_name AS name, 'SEQUENCE' AS type FROM information_schema.sequences WHERE sequence_schema='public' + ) LOOP + -- Use %s for type to avoid quoting the SQL keyword + EXECUTE format('DROP %s public.%I CASCADE', r.type, r.name); + END LOOP; +END +$$; +""" + run_command([ + 'docker', 'exec', '-i', container, + 'psql', '-v', 'ON_ERROR_STOP=1', '-U', user, '-d', db_name + ], input=drop_sql.encode()) + print("Existing objects dropped.") + print("Importing the dump...") + with open(backup_sql, 'rb') as f: + run_command([ + 'docker', 'exec', '-i', container, + 'psql', '-v', 'ON_ERROR_STOP=1', '-U', user, '-d', db_name + ], stdin=f) + print("PostgreSQL recovery complete.") + + +def recover_mariadb(container, password, db_name, user, backup_sql, empty=False): + print("Recovering MariaDB dump...") + if empty: + print("Dropping existing MariaDB tables...") + # Disable foreign key checks + run_command([ + 'docker', 'exec', container, + 'mysql', '-u', user, f"--password={password}", '-e', 'SET FOREIGN_KEY_CHECKS=0;' + ]) + # Get all table names + result = run_command([ + 'docker', 'exec', container, + 'mysql', '-u', user, f"--password={password}", '-N', '-e', + f"SELECT table_name FROM information_schema.tables WHERE table_schema = '{db_name}';" + ], capture_output=True) + tables = result.stdout.decode().split() + for tbl in tables: + run_command([ + 'docker', 'exec', container, + 'mysql', '-u', user, f"--password={password}", '-e', + f"DROP TABLE IF EXISTS `{db_name}`.`{tbl}`;" + ]) + # Enable foreign key checks + run_command([ + 'docker', 'exec', container, + 'mysql', '-u', user, f"--password={password}", '-e', 'SET FOREIGN_KEY_CHECKS=1;' + ]) + print("Existing tables dropped.") + print("Importing the dump...") + with open(backup_sql, 'rb') as f: + run_command([ + 'docker', 'exec', '-i', container, + 'mariadb', '-u', user, f"--password={password}", db_name + ], stdin=f) + print("MariaDB recovery complete.") + + +def recover_files(volume_name, backup_files): + print(f"Inspecting volume {volume_name}...") + inspect = subprocess.run(['docker', 'volume', 'inspect', volume_name], stdout=subprocess.DEVNULL) + if inspect.returncode != 0: + print(f"Volume {volume_name} does not exist. Creating...") + run_command(['docker', 'volume', 'create', volume_name]) + else: + print(f"Volume {volume_name} already exists.") + + if not os.path.isdir(backup_files): + print(f"ERROR: Backup files folder '{backup_files}' does not exist.") + sys.exit(1) + + print("Recovering files...") + run_command([ + 'docker', 'run', '--rm', + '-v', f"{volume_name}:/recover/", + '-v', f"{backup_files}:/backup/", + 'kevinveenbirkenbach/alpine-rsync', + 'sh', '-c', 'rsync -avv --delete /backup/ /recover/' + ]) + print("File recovery complete.") + + +def main(): + parser = argparse.ArgumentParser( + description='Recover Docker volumes and database dumps from local backups.' + ) + parser.add_argument('volume_name', help='Name of the Docker volume') + parser.add_argument('backup_hash', help='Hashed Machine ID') + parser.add_argument('version', help='Version to recover') + + parser.add_argument('--db-type', choices=['postgres', 'mariadb'], help='Type of database backup') + parser.add_argument('--db-container', help='Docker container name for the database') + parser.add_argument('--db-password', help='Password for the database user') + parser.add_argument('--db-name', help='Name of the database') + parser.add_argument('--empty', action='store_true', help='Drop existing database objects before importing') + + args = parser.parse_args() + + volume = args.volume_name + backup_hash = args.backup_hash + version = args.version + + backup_folder = os.path.join('Backups', backup_hash, 'backup-docker-to-local', version, volume) + backup_files = os.path.join(os.sep, backup_folder, 'files') + backup_sql = None + if args.db_name: + backup_sql = os.path.join(os.sep, backup_folder, 'sql', f"{args.db_name}.backup.sql") + + # Database recovery + if args.db_type: + if not (args.db_container and args.db_password and args.db_name): + print("ERROR: A database backup exists, aber ein Parameter fehlt.") + sys.exit(1) + + user = args.db_name + if args.db_type == 'postgres': + recover_postgres(args.db_container, args.db_password, args.db_name, user, backup_sql, empty=args.empty) + else: + recover_mariadb(args.db_container, args.db_password, args.db_name, user, backup_sql, empty=args.empty) + sys.exit(0) + + # File recovery + recover_files(volume, backup_files) + + +if __name__ == '__main__': + main() diff --git a/restore_postgres_databases.py b/restore_postgres_databases.py new file mode 100644 index 0000000..bcb182e --- /dev/null +++ b/restore_postgres_databases.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Restore multiple PostgreSQL databases from .backup.sql files via a Docker container. + +Usage: + ./restore_databases.py /path/to/backup_dir [--container central-postgres] +""" +import argparse +import subprocess +import sys +import os +import glob + +def run_command(cmd, input_data=None): + """ + Run a subprocess command and exit on failure. + :param cmd: list of command parts + :param input_data: bytes to send to process stdin + """ + try: + subprocess.run(cmd, input=input_data, check=True) + except subprocess.CalledProcessError as e: + print(f"Error running command: {' '.join(cmd)}", file=sys.stderr) + sys.exit(e.returncode) + + +def main(): + parser = argparse.ArgumentParser( + description="Restore Postgres databases from backup SQL files via Docker container." + ) + parser.add_argument( + "backup_dir", + help="Path to directory containing .backup.sql files" + ) + parser.add_argument( + "container", + help="Name of the Postgres Docker container" + ) + args = parser.parse_args() + + backup_dir = args.backup_dir + container = args.container + + pattern = os.path.join(backup_dir, "*.backup.sql") + sql_files = sorted(glob.glob(pattern)) + if not sql_files: + print(f"No .backup.sql files found in {backup_dir}", file=sys.stderr) + sys.exit(1) + + for sqlfile in sql_files: + dbname = os.path.splitext(os.path.basename(sqlfile))[0] + print(f"=== Processing {sqlfile} → database: {dbname} ===") + + # Drop the database if it already exists + run_command([ + "docker", "exec", "-i", container, + "psql", "-U", "postgres", "-c", + f"DROP DATABASE IF EXISTS \"{dbname}\";" + ]) + + # Create a fresh database + run_command([ + "docker", "exec", "-i", container, + "psql", "-U", "postgres", "-c", + f"CREATE DATABASE \"{dbname}\";" + ]) + + # Restore the dump into the newly created database + print(f"Restoring dump into {dbname}…") + with open(sqlfile, "rb") as f: + sql_data = f.read() + run_command([ + "docker", "exec", "-i", container, + "psql", "-U", "postgres", "-d", dbname + ], input_data=sql_data) + + print(f"✔ {dbname} restored.") + + print("All databases have been restored.") + + +if __name__ == "__main__": + main()