From 789b28098b07fd4055654195cc5db4bb2dbbebe8 Mon Sep 17 00:00:00 2001 From: muebau Date: Tue, 17 Mar 2026 16:40:24 +0100 Subject: [PATCH] feat: add ATOMIC_BACKUP mode for single-snapshot volumes + database backups When ATOMIC_BACKUP=true, database dumps are written to /databases/ on the backup container's filesystem and then backed up together with /volumes in a single `restic backup` call, producing one atomic snapshot. This ensures volumes and database dumps are always consistent and restorable as a unit. Without the flag (default), the existing behavior is preserved: volumes and each database are backed up as separate restic snapshots. Changes: - config.py: read ATOMIC_BACKUP env var - commands.py: add docker_exec_to_file() to stream docker exec stdout to a local file - containers_db.py: add dump_to_file() to MariaDB, MySQL, PostgreSQL container subclasses - containers.py: add dump_to_file() abstract method to base Container - restic.py: backup_files() now accepts a list of source paths - cli.py: start_backup_process() branches into atomic/standard mode; status() reports atomic backup setting - fixtures.py: include Env in container fixture for DB credential tests - test_atomic_backup.py: 12 unit tests covering config, dump paths, dump_to_file delegation, and multi-source backup_files --- src/restic_compose_backup/cli.py | 816 +++++++++-------- src/restic_compose_backup/commands.py | 307 ++++--- src/restic_compose_backup/config.py | 157 ++-- src/restic_compose_backup/containers.py | 992 +++++++++++---------- src/restic_compose_backup/containers_db.py | 486 +++++----- src/restic_compose_backup/restic.py | 371 ++++---- src/tests/unit/fixtures.py | 121 +-- src/tests/unit/test_atomic_backup.py | 237 +++++ 8 files changed, 1956 insertions(+), 1531 deletions(-) create mode 100644 src/tests/unit/test_atomic_backup.py diff --git a/src/restic_compose_backup/cli.py b/src/restic_compose_backup/cli.py index e395e02..11f1e0b 100644 --- a/src/restic_compose_backup/cli.py +++ b/src/restic_compose_backup/cli.py @@ -1,374 +1,442 @@ -import argparse -import os -import logging - -from restic_compose_backup import ( - alerts, - backup_runner, - log, - restic, -) -from restic_compose_backup.config import Config -from restic_compose_backup.containers import RunningContainers -from restic_compose_backup import cron, utils - -logger = logging.getLogger(__name__) - - -def main(): - """CLI entrypoint""" - args = parse_args() - config = Config() - log.setup(level=args.log_level or config.log_level) - containers = RunningContainers() - - # Ensure log level is propagated to parent container if overridden - if args.log_level: - containers.this_container.set_config_env("LOG_LEVEL", args.log_level) - - if args.action == "status": - status(config, containers) - - elif args.action == "snapshots": - snapshots(config, containers) - - elif args.action == "backup": - backup(config, containers) - - elif args.action == "start-backup-process": - start_backup_process(config, containers) - - elif args.action == "maintenance": - maintenance(config, containers) - - elif args.action == "cleanup": - cleanup(config, containers) - - elif args.action == "alert": - alert(config, containers) - - elif args.action == "version": - import restic_compose_backup - - print(restic_compose_backup.__version__) - - elif args.action == "crontab": - crontab(config) - - elif args.action == "dump-env": - dump_env() - - # Random test stuff here - elif args.action == "test": - nodes = utils.get_swarm_nodes() - print("Swarm nodes:") - for node in nodes: - addr = node.attrs["Status"]["Addr"] - state = node.attrs["Status"]["State"] - print(" - {} {} {}".format(node.id, addr, state)) - - -def status(config, containers): - """Outputs the backup config for the compose setup""" - logger.info("Status for compose project '%s'", containers.project_name) - logger.info("Repository: '%s'", config.repository) - logger.info("Backup currently running?: %s", containers.backup_process_running) - logger.info( - "Include project name in backup path?: %s", - utils.is_true(config.include_project_name), - ) - logger.debug( - "Exclude bind mounts from backups?: %s", - utils.is_true(config.exclude_bind_mounts), - ) - logger.debug( - "Include all compose projects?: %s", - utils.is_true(config.include_all_compose_projects), - ) - logger.debug( - f"Use cache for integrity check?: {utils.is_true(config.check_with_cache)}" - ) - logger.info("Checking docker availability") - - utils.list_containers() - - if containers.stale_backup_process_containers: - utils.remove_containers(containers.stale_backup_process_containers) - - logger.info("Contacting repository") - if not restic.is_initialized(config.repository): - logger.info("Repository is not initialized. Attempting to initialize it.") - result = restic.init_repo(config.repository) - if result == 0: - logger.info("Successfully initialized repository: %s", config.repository) - else: - logger.error("Failed to initialize repository") - - logger.info("%s Detected Config %s", "-" * 25, "-" * 25) - - # Start making snapshots - backup_containers = containers.containers_for_backup() - for container in backup_containers: - logger.info("service: %s", container.service_name) - - if container.volume_backup_enabled: - logger.info(f" - stop during backup: {container.stop_during_backup}") - for mount in container.filter_mounts(): - logger.info( - " - volume: %s -> %s", - mount.source, - container.get_volume_backup_destination(mount, "/volumes"), - ) - - if container.database_backup_enabled: - instance = container.instance - ping = instance.ping() - logger.info( - " - %s (is_ready=%s) -> %s", - instance.container_type, - ping, - instance.backup_destination_path(), - ) - if not ping: - logger.error( - "Database '%s' in service %s cannot be reached", - instance.container_type, - container.service_name, - ) - - if len(backup_containers) == 0: - logger.info("No containers in the project has 'stack-back.*' label") - - logger.info("-" * 67) - - -def backup(config, containers: RunningContainers): - """Request a backup to start""" - # Make sure we don't spawn multiple backup processes - if containers.backup_process_running: - alerts.send( - subject="Backup process container already running", - body=( - "A backup process container is already running. \n" - f"Id: {containers.backup_process_container.id}\n" - f"Name: {containers.backup_process_container.name}\n" - ), - alert_type="ERROR", - ) - raise RuntimeError("Backup process already running") - - # Map all volumes from the backup container into the backup process container - volumes = containers.this_container.volumes - - # Map volumes from other containers we are backing up - mounts = containers.generate_backup_mounts("/volumes") - volumes.update(mounts) - - logger.debug( - "Starting backup container with image %s", containers.this_container.image - ) - try: - result = backup_runner.run( - image=containers.this_container.image, - command="rcb start-backup-process", - volumes=volumes, - environment=containers.this_container.environment, - source_container_id=containers.this_container.id, - labels={ - containers.backup_process_label: "True", - "com.docker.compose.project": containers.project_name, - }, - ) - except Exception as ex: - logger.exception(ex) - alerts.send( - subject="Exception during backup", - body=str(ex), - alert_type="ERROR", - ) - return - - logger.info("Backup container exit code: %s", result) - - # Alert the user if something went wrong - if result != 0: - alerts.send( - subject="Backup process exited with non-zero code", - body=open("backup.log").read(), - alert_type="ERROR", - ) - - -def start_backup_process(config, containers): - """The actual backup process running inside the spawned container""" - if not utils.is_true(os.environ.get("BACKUP_PROCESS_CONTAINER")): - logger.error( - "Cannot run backup process in this container. Use backup command instead. " - "This will spawn a new container with the necessary mounts." - ) - alerts.send( - subject="Cannot run backup process in this container", - body=( - "Cannot run backup process in this container. Use backup command instead. " - "This will spawn a new container with the necessary mounts." - ), - ) - exit(1) - - status(config, containers) - errors = False - - # Did we actually get any volumes mounted? - try: - has_volumes = os.stat("/volumes") is not None - except FileNotFoundError: - logger.warning("Found no volumes to back up") - has_volumes = False - - # Warn if there is nothing to do - if len(containers.containers_for_backup()) == 0 and not has_volumes: - logger.error("No containers for backup found") - exit(1) - - # stop containers labeled to stop during backup - if len(containers.stop_during_backup_containers) > 0: - utils.stop_containers(containers.stop_during_backup_containers) - - # back up volumes - if has_volumes: - try: - logger.info("Backing up volumes") - vol_result = restic.backup_files(config.repository, source="/volumes") - logger.debug("Volume backup exit code: %s", vol_result) - if vol_result != 0: - logger.error("Volume backup exited with non-zero code: %s", vol_result) - errors = True - except Exception as ex: - logger.error("Exception raised during volume backup") - logger.exception(ex) - errors = True - - # back up databases - logger.info("Backing up databases") - for container in containers.containers_for_backup(): - if container.database_backup_enabled: - try: - instance = container.instance - logger.debug( - "Backing up %s in service %s from project %s", - instance.container_type, - instance.service_name, - instance.project_name, - ) - result = instance.backup() - logger.debug("Exit code: %s", result) - if result != 0: - logger.error("Backup command exited with non-zero code: %s", result) - errors = True - except Exception as ex: - logger.exception(ex) - errors = True - - # restart stopped containers after backup - if len(containers.stop_during_backup_containers) > 0: - utils.start_containers(containers.stop_during_backup_containers) - - if errors: - logger.error("Exit code: %s", errors) - exit(1) - - # Only run maintenance tasks if maintenance is not scheduled - if not config.maintenance_schedule: - maintenance(config, containers) - - logger.info("Backup completed") - - -def maintenance(config, containers): - """Run maintenance tasks""" - logger.info("Running maintenance tasks") - result = cleanup(config, containers) - if result != 0: - logger.error("Cleanup exit code: %s", result) - exit(1) - - logger.info("Checking the repository for errors") - check_with_cache = utils.is_true(config.check_with_cache) - result = restic.check(config.repository, with_cache=check_with_cache) - if result != 0: - logger.error("Check exit code: %s", result) - exit(1) - - -def cleanup(config, containers): - """Run forget / prune to minimize storage space""" - logger.info("Forget outdated snapshots") - forget_result = restic.forget( - config.repository, - config.keep_daily, - config.keep_weekly, - config.keep_monthly, - config.keep_yearly, - ) - logger.info("Prune stale data freeing storage space") - prune_result = restic.prune(config.repository) - return forget_result and prune_result - - -def snapshots(config, containers): - """Display restic snapshots""" - stdout, stderr = restic.snapshots(config.repository, last=True) - for line in stdout.decode().split("\n"): - print(line) - - -def alert(config, containers): - """Test alerts""" - logger.info("Testing alerts") - alerts.send( - subject="{}: Test Alert".format(containers.project_name), - body="Test message", - ) - - -def crontab(config): - """Generate the crontab""" - print(cron.generate_crontab(config)) - - -def dump_env(): - """Dump all environment variables to a file that can be sourced from cron""" - print("# This file was generated by stack-back") - for key, value in os.environ.items(): - print("export {}='{}'".format(key, value)) - - -def parse_args(): - parser = argparse.ArgumentParser(prog="restic_compose_backup") - parser.add_argument( - "action", - choices=[ - "status", - "snapshots", - "backup", - "start-backup-process", - "maintenance", - "alert", - "cleanup", - "version", - "crontab", - "dump-env", - "test", - ], - ) - parser.add_argument( - "--log-level", - default=None, - choices=list(log.LOG_LEVELS.keys()), - help="Log level", - ) - return parser.parse_args() - - -if __name__ == "__main__": - main() +import argparse +import os +import logging +import shutil + +from restic_compose_backup import ( + alerts, + backup_runner, + log, + restic, +) +from restic_compose_backup.config import Config +from restic_compose_backup.containers import RunningContainers +from restic_compose_backup import cron, utils + +logger = logging.getLogger(__name__) + + +def main(): + """CLI entrypoint""" + args = parse_args() + config = Config() + log.setup(level=args.log_level or config.log_level) + containers = RunningContainers() + + # Ensure log level is propagated to parent container if overridden + if args.log_level: + containers.this_container.set_config_env("LOG_LEVEL", args.log_level) + + if args.action == "status": + status(config, containers) + + elif args.action == "snapshots": + snapshots(config, containers) + + elif args.action == "backup": + backup(config, containers) + + elif args.action == "start-backup-process": + start_backup_process(config, containers) + + elif args.action == "maintenance": + maintenance(config, containers) + + elif args.action == "cleanup": + cleanup(config, containers) + + elif args.action == "alert": + alert(config, containers) + + elif args.action == "version": + import restic_compose_backup + + print(restic_compose_backup.__version__) + + elif args.action == "crontab": + crontab(config) + + elif args.action == "dump-env": + dump_env() + + # Random test stuff here + elif args.action == "test": + nodes = utils.get_swarm_nodes() + print("Swarm nodes:") + for node in nodes: + addr = node.attrs["Status"]["Addr"] + state = node.attrs["Status"]["State"] + print(" - {} {} {}".format(node.id, addr, state)) + + +def status(config, containers): + """Outputs the backup config for the compose setup""" + logger.info("Status for compose project '%s'", containers.project_name) + logger.info("Repository: '%s'", config.repository) + logger.info("Backup currently running?: %s", containers.backup_process_running) + logger.info( + "Include project name in backup path?: %s", + utils.is_true(config.include_project_name), + ) + logger.debug( + "Exclude bind mounts from backups?: %s", + utils.is_true(config.exclude_bind_mounts), + ) + logger.debug( + "Include all compose projects?: %s", + utils.is_true(config.include_all_compose_projects), + ) + logger.debug( + f"Use cache for integrity check?: {utils.is_true(config.check_with_cache)}" + ) + logger.info( + "Atomic backup (volumes + databases in one snapshot)?: %s", + utils.is_true(config.atomic_backup), + ) + logger.info("Checking docker availability") + + utils.list_containers() + + if containers.stale_backup_process_containers: + utils.remove_containers(containers.stale_backup_process_containers) + + logger.info("Contacting repository") + if not restic.is_initialized(config.repository): + logger.info("Repository is not initialized. Attempting to initialize it.") + result = restic.init_repo(config.repository) + if result == 0: + logger.info("Successfully initialized repository: %s", config.repository) + else: + logger.error("Failed to initialize repository") + + logger.info("%s Detected Config %s", "-" * 25, "-" * 25) + + # Start making snapshots + backup_containers = containers.containers_for_backup() + for container in backup_containers: + logger.info("service: %s", container.service_name) + + if container.volume_backup_enabled: + logger.info(f" - stop during backup: {container.stop_during_backup}") + for mount in container.filter_mounts(): + logger.info( + " - volume: %s -> %s", + mount.source, + container.get_volume_backup_destination(mount, "/volumes"), + ) + + if container.database_backup_enabled: + instance = container.instance + ping = instance.ping() + logger.info( + " - %s (is_ready=%s) -> %s", + instance.container_type, + ping, + instance.backup_destination_path(), + ) + if not ping: + logger.error( + "Database '%s' in service %s cannot be reached", + instance.container_type, + container.service_name, + ) + + if len(backup_containers) == 0: + logger.info("No containers in the project has 'stack-back.*' label") + + logger.info("-" * 67) + + +def backup(config, containers: RunningContainers): + """Request a backup to start""" + # Make sure we don't spawn multiple backup processes + if containers.backup_process_running: + alerts.send( + subject="Backup process container already running", + body=( + "A backup process container is already running. \n" + f"Id: {containers.backup_process_container.id}\n" + f"Name: {containers.backup_process_container.name}\n" + ), + alert_type="ERROR", + ) + raise RuntimeError("Backup process already running") + + # Map all volumes from the backup container into the backup process container + volumes = containers.this_container.volumes + + # Map volumes from other containers we are backing up + mounts = containers.generate_backup_mounts("/volumes") + volumes.update(mounts) + + logger.debug( + "Starting backup container with image %s", containers.this_container.image + ) + try: + result = backup_runner.run( + image=containers.this_container.image, + command="rcb start-backup-process", + volumes=volumes, + environment=containers.this_container.environment, + source_container_id=containers.this_container.id, + labels={ + containers.backup_process_label: "True", + "com.docker.compose.project": containers.project_name, + }, + ) + except Exception as ex: + logger.exception(ex) + alerts.send( + subject="Exception during backup", + body=str(ex), + alert_type="ERROR", + ) + return + + logger.info("Backup container exit code: %s", result) + + # Alert the user if something went wrong + if result != 0: + alerts.send( + subject="Backup process exited with non-zero code", + body=open("backup.log").read(), + alert_type="ERROR", + ) + + +def start_backup_process(config, containers): + """The actual backup process running inside the spawned container""" + if not utils.is_true(os.environ.get("BACKUP_PROCESS_CONTAINER")): + logger.error( + "Cannot run backup process in this container. Use backup command instead. " + "This will spawn a new container with the necessary mounts." + ) + alerts.send( + subject="Cannot run backup process in this container", + body=( + "Cannot run backup process in this container. Use backup command instead. " + "This will spawn a new container with the necessary mounts." + ), + ) + exit(1) + + status(config, containers) + errors = False + + # Did we actually get any volumes mounted? + try: + has_volumes = os.stat("/volumes") is not None + except FileNotFoundError: + logger.warning("Found no volumes to back up") + has_volumes = False + + # Warn if there is nothing to do + if len(containers.containers_for_backup()) == 0 and not has_volumes: + logger.error("No containers for backup found") + exit(1) + + # stop containers labeled to stop during backup + if len(containers.stop_during_backup_containers) > 0: + utils.stop_containers(containers.stop_during_backup_containers) + + if utils.is_true(config.atomic_backup): + # --- Atomic mode --- + # Dump databases to files in /databases/, then back up /volumes and + # /databases together in a single restic snapshot. + logger.info("Atomic backup mode: dumping databases to disk") + for container in containers.containers_for_backup(): + if container.database_backup_enabled: + try: + instance = container.instance + logger.debug( + "Dumping %s in service %s from project %s", + instance.container_type, + instance.service_name, + instance.project_name, + ) + result = instance.dump_to_file() + logger.debug("Database dump exit code: %s", result) + if result != 0: + logger.error( + "Database dump exited with non-zero code: %s", result + ) + errors = True + except Exception as ex: + logger.exception(ex) + errors = True + + # Collect backup sources + sources = [] + if has_volumes: + sources.append("/volumes") + try: + has_databases = os.path.isdir("/databases") and len( + os.listdir("/databases") + ) > 0 + except OSError: + has_databases = False + if has_databases: + sources.append("/databases") + + if sources: + try: + logger.info( + "Backing up %s in a single snapshot", " and ".join(sources) + ) + result = restic.backup_files(config.repository, source=sources) + logger.debug("Backup exit code: %s", result) + if result != 0: + logger.error( + "Backup exited with non-zero code: %s", result + ) + errors = True + except Exception as ex: + logger.error("Exception raised during backup") + logger.exception(ex) + errors = True + + # Clean up dump files + if os.path.exists("/databases"): + shutil.rmtree("/databases", ignore_errors=True) + else: + # --- Standard mode --- + # Volumes and each database are backed up as separate restic snapshots. + + # back up volumes + if has_volumes: + try: + logger.info("Backing up volumes") + vol_result = restic.backup_files(config.repository, source="/volumes") + logger.debug("Volume backup exit code: %s", vol_result) + if vol_result != 0: + logger.error("Volume backup exited with non-zero code: %s", vol_result) + errors = True + except Exception as ex: + logger.error("Exception raised during volume backup") + logger.exception(ex) + errors = True + + # back up databases + logger.info("Backing up databases") + for container in containers.containers_for_backup(): + if container.database_backup_enabled: + try: + instance = container.instance + logger.debug( + "Backing up %s in service %s from project %s", + instance.container_type, + instance.service_name, + instance.project_name, + ) + result = instance.backup() + logger.debug("Exit code: %s", result) + if result != 0: + logger.error("Backup command exited with non-zero code: %s", result) + errors = True + except Exception as ex: + logger.exception(ex) + errors = True + + # restart stopped containers after backup + if len(containers.stop_during_backup_containers) > 0: + utils.start_containers(containers.stop_during_backup_containers) + + if errors: + logger.error("Exit code: %s", errors) + exit(1) + + # Only run maintenance tasks if maintenance is not scheduled + if not config.maintenance_schedule: + maintenance(config, containers) + + logger.info("Backup completed") + + +def maintenance(config, containers): + """Run maintenance tasks""" + logger.info("Running maintenance tasks") + result = cleanup(config, containers) + if result != 0: + logger.error("Cleanup exit code: %s", result) + exit(1) + + logger.info("Checking the repository for errors") + check_with_cache = utils.is_true(config.check_with_cache) + result = restic.check(config.repository, with_cache=check_with_cache) + if result != 0: + logger.error("Check exit code: %s", result) + exit(1) + + +def cleanup(config, containers): + """Run forget / prune to minimize storage space""" + logger.info("Forget outdated snapshots") + forget_result = restic.forget( + config.repository, + config.keep_daily, + config.keep_weekly, + config.keep_monthly, + config.keep_yearly, + ) + logger.info("Prune stale data freeing storage space") + prune_result = restic.prune(config.repository) + return forget_result and prune_result + + +def snapshots(config, containers): + """Display restic snapshots""" + stdout, stderr = restic.snapshots(config.repository, last=True) + for line in stdout.decode().split("\n"): + print(line) + + +def alert(config, containers): + """Test alerts""" + logger.info("Testing alerts") + alerts.send( + subject="{}: Test Alert".format(containers.project_name), + body="Test message", + ) + + +def crontab(config): + """Generate the crontab""" + print(cron.generate_crontab(config)) + + +def dump_env(): + """Dump all environment variables to a file that can be sourced from cron""" + print("# This file was generated by stack-back") + for key, value in os.environ.items(): + print("export {}='{}'".format(key, value)) + + +def parse_args(): + parser = argparse.ArgumentParser(prog="restic_compose_backup") + parser.add_argument( + "action", + choices=[ + "status", + "snapshots", + "backup", + "start-backup-process", + "maintenance", + "alert", + "cleanup", + "version", + "crontab", + "dump-env", + "test", + ], + ) + parser.add_argument( + "--log-level", + default=None, + choices=list(log.LOG_LEVELS.keys()), + help="Log level", + ) + return parser.parse_args() + + +if __name__ == "__main__": + main() diff --git a/src/restic_compose_backup/commands.py b/src/restic_compose_backup/commands.py index 057ff2b..7b2a7ad 100644 --- a/src/restic_compose_backup/commands.py +++ b/src/restic_compose_backup/commands.py @@ -1,129 +1,178 @@ -import logging -from typing import List, Tuple, Union -from restic_compose_backup import utils -from subprocess import Popen, PIPE - -logger = logging.getLogger(__name__) - - -def test(): - return run(["ls", "/volumes"]) - - -def ping_mysql(container_id, host, port, username, password) -> int: - """Check if the mysql is up and can be reached""" - return docker_exec( - container_id, - [ - "mysqladmin", - "ping", - "--host", - host, - "--port", - port, - "--user", - username, - ], - environment={"MYSQL_PWD": password}, - ) - - -def ping_mariadb(container_id, host, port, username, password) -> int: - """Check if the mariadb is up and can be reached""" - return docker_exec( - container_id, - [ - "mariadb-admin", - "ping", - "--host", - host, - "--port", - port, - "--user", - username, - ], - environment={"MYSQL_PWD": password}, - ) - - -def ping_postgres(container_id, host, port, username, password) -> int: - """Check if postgres can be reached""" - return docker_exec( - container_id, - [ - "pg_isready", - f"--host={host}", - f"--port={port}", - f"--username={username}", - ], - ) - - -def docker_exec( - container_id: str, cmd: List[str], environment: Union[dict, list] = [] -) -> int: - """Execute a command within the given container""" - client = utils.docker_client() - logger.debug("docker exec inside %s: %s", container_id, " ".join(cmd)) - exit_code, (stdout, stderr) = client.containers.get(container_id).exec_run( - cmd, demux=True, environment=environment - ) - - if stdout: - log_std( - "stdout", - stdout.decode(), - logging.DEBUG if exit_code == 0 else logging.ERROR, - ) - - if stderr: - log_std("stderr", stderr.decode(), logging.ERROR) - - return exit_code - - -def run(cmd: List[str]) -> int: - """Run a command with parameters""" - logger.debug("cmd: %s", " ".join(cmd)) - child = Popen(cmd, stdout=PIPE, stderr=PIPE) - stdoutdata, stderrdata = child.communicate() - - if stdoutdata.strip(): - log_std( - "stdout", - stdoutdata.decode(), - logging.DEBUG if child.returncode == 0 else logging.ERROR, - ) - - if stderrdata.strip(): - log_std("stderr", stderrdata.decode(), logging.ERROR) - - logger.debug("returncode %s", child.returncode) - return child.returncode - - -def run_capture_std(cmd: List[str]) -> Tuple[str, str]: - """Run a command with parameters and return stdout, stderr""" - logger.debug("cmd: %s", " ".join(cmd)) - child = Popen(cmd, stdout=PIPE, stderr=PIPE) - return child.communicate() - - -def log_std(source: str, data: str, level: int): - if isinstance(data, bytes): - data = data.decode() - - if not data.strip(): - return - - log_func = logger.debug if level == logging.DEBUG else logger.error - log_func("%s %s %s", "-" * 10, source, "-" * 10) - - lines = data.split("\n") - if lines[-1] == "": - lines.pop() - - for line in lines: - log_func(line) - - log_func("-" * 28) +import logging +import os +from typing import List, Tuple, Union +from restic_compose_backup import utils +from subprocess import Popen, PIPE + +logger = logging.getLogger(__name__) + + +def test(): + return run(["ls", "/volumes"]) + + +def docker_exec_to_file( + container_id: str, + cmd: List[str], + file_path: str, + environment: Union[dict, list] = None, +) -> int: + """Execute a command in a container and write its stdout to a local file. + + Used by atomic backup mode to dump database contents to the backup + container's filesystem before a single ``restic backup`` call. + + Args: + container_id: Docker container to exec into. + cmd: Command and arguments to run inside the container. + file_path: Local path where stdout will be written (directories + are created automatically). + environment: Optional env vars passed to the exec call. + + Returns: + Exit code of the command executed inside the container. + """ + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + client = utils.docker_client() + logger.debug( + "docker exec inside %s: %s > %s", container_id, " ".join(cmd), file_path + ) + + handle = client.api.exec_create(container_id, cmd, environment=environment) + exec_id = handle.get("Id") + stream = client.api.exec_start(exec_id, stream=True, demux=True) + source_stderr = "" + + with open(file_path, "wb") as f: + for stdout_chunk, stderr_chunk in stream: + if stdout_chunk: + f.write(stdout_chunk) + if stderr_chunk: + source_stderr += stderr_chunk.decode() + + exit_code = client.api.exec_inspect(exec_id).get("ExitCode") + + if source_stderr: + log_std(f"stderr ({cmd[0]})", source_stderr, logging.ERROR) + + return exit_code + + +def ping_mysql(container_id, host, port, username, password) -> int: + """Check if the mysql is up and can be reached""" + return docker_exec( + container_id, + [ + "mysqladmin", + "ping", + "--host", + host, + "--port", + port, + "--user", + username, + ], + environment={"MYSQL_PWD": password}, + ) + + +def ping_mariadb(container_id, host, port, username, password) -> int: + """Check if the mariadb is up and can be reached""" + return docker_exec( + container_id, + [ + "mariadb-admin", + "ping", + "--host", + host, + "--port", + port, + "--user", + username, + ], + environment={"MYSQL_PWD": password}, + ) + + +def ping_postgres(container_id, host, port, username, password) -> int: + """Check if postgres can be reached""" + return docker_exec( + container_id, + [ + "pg_isready", + f"--host={host}", + f"--port={port}", + f"--username={username}", + ], + ) + + +def docker_exec( + container_id: str, cmd: List[str], environment: Union[dict, list] = [] +) -> int: + """Execute a command within the given container""" + client = utils.docker_client() + logger.debug("docker exec inside %s: %s", container_id, " ".join(cmd)) + exit_code, (stdout, stderr) = client.containers.get(container_id).exec_run( + cmd, demux=True, environment=environment + ) + + if stdout: + log_std( + "stdout", + stdout.decode(), + logging.DEBUG if exit_code == 0 else logging.ERROR, + ) + + if stderr: + log_std("stderr", stderr.decode(), logging.ERROR) + + return exit_code + + +def run(cmd: List[str]) -> int: + """Run a command with parameters""" + logger.debug("cmd: %s", " ".join(cmd)) + child = Popen(cmd, stdout=PIPE, stderr=PIPE) + stdoutdata, stderrdata = child.communicate() + + if stdoutdata.strip(): + log_std( + "stdout", + stdoutdata.decode(), + logging.DEBUG if child.returncode == 0 else logging.ERROR, + ) + + if stderrdata.strip(): + log_std("stderr", stderrdata.decode(), logging.ERROR) + + logger.debug("returncode %s", child.returncode) + return child.returncode + + +def run_capture_std(cmd: List[str]) -> Tuple[str, str]: + """Run a command with parameters and return stdout, stderr""" + logger.debug("cmd: %s", " ".join(cmd)) + child = Popen(cmd, stdout=PIPE, stderr=PIPE) + return child.communicate() + + +def log_std(source: str, data: str, level: int): + if isinstance(data, bytes): + data = data.decode() + + if not data.strip(): + return + + log_func = logger.debug if level == logging.DEBUG else logger.error + log_func("%s %s %s", "-" * 10, source, "-" * 10) + + lines = data.split("\n") + if lines[-1] == "": + lines.pop() + + for line in lines: + log_func(line) + + log_func("-" * 28) diff --git a/src/restic_compose_backup/config.py b/src/restic_compose_backup/config.py index 93c3a22..ef93c49 100644 --- a/src/restic_compose_backup/config.py +++ b/src/restic_compose_backup/config.py @@ -1,78 +1,79 @@ -import logging -import os - -logger = logging.getLogger(__name__) - - -class Config: - default_backup_command = "source /.env && rcb backup > /proc/1/fd/1" - default_crontab_schedule = "0 2 * * *" - default_maintenance_command = "source /.env && rcb maintenance > /proc/1/fd/1" - - """Bag for config values""" - - def __init__(self, check=True): - # Mandatory values - self.repository = os.environ.get("RESTIC_REPOSITORY") - self.password = os.environ.get("RESTIC_REPOSITORY") - self.check_with_cache = os.environ.get("CHECK_WITH_CACHE") or False - self.cron_schedule = ( - os.environ.get("CRON_SCHEDULE") or self.default_crontab_schedule - ) - self.cron_command = ( - os.environ.get("CRON_COMMAND") or self.default_backup_command - ) - self.maintenance_schedule = os.environ.get("MAINTENANCE_SCHEDULE") or "" - self.maintenance_command = ( - os.environ.get("MAINTENANCE_COMMAND") or self.default_maintenance_command - ) - self.swarm_mode = os.environ.get("SWARM_MODE") or False - self.exclude_bind_mounts = os.environ.get("EXCLUDE_BIND_MOUNTS") or False - self.include_all_compose_projects = ( - os.environ.get("INCLUDE_ALL_COMPOSE_PROJECTS") or False - ) - self.include_project_name = ( - os.environ.get("INCLUDE_ALL_COMPOSE_PROJECTS") - or os.environ.get("INCLUDE_PROJECT_NAME") - or False - ) - self.include_all_volumes = os.environ.get("INCLUDE_ALL_VOLUMES") or False - if self.include_all_volumes: - logger.warning( - "INCLUDE_ALL_VOLUMES will be deprecated in the future in favor of AUTO_BACKUP_ALL. Please update your environment variables." - ) - self.auto_backup_all = ( - os.environ.get("AUTO_BACKUP_ALL") or self.include_all_volumes - ) - - # Log - self.log_level = os.environ.get("LOG_LEVEL") - - # forget / keep - self.keep_daily = ( - os.environ.get("RESTIC_KEEP_DAILY") or os.environ.get("KEEP_DAILY") or "7" - ) - self.keep_weekly = ( - os.environ.get("RESTIC_KEEP_WEEKLY") or os.environ.get("KEEP_WEEKLY") or "4" - ) - self.keep_monthly = ( - os.environ.get("RESTIC_KEEP_MONTHLY") - or os.environ.get("KEEP_MONTHLY") - or "12" - ) - self.keep_yearly = ( - os.environ.get("RESTIC_KEEP_YEARLY") or os.environ.get("KEEP_YEARLY") or "3" - ) - - if check: - self.check() - - def check(self): - if not self.repository: - raise ValueError("RESTIC_REPOSITORY env var not set") - - if not self.password: - raise ValueError("RESTIC_REPOSITORY env var not set") - - -config = Config() +import logging +import os + +logger = logging.getLogger(__name__) + + +class Config: + default_backup_command = "source /.env && rcb backup > /proc/1/fd/1" + default_crontab_schedule = "0 2 * * *" + default_maintenance_command = "source /.env && rcb maintenance > /proc/1/fd/1" + + """Bag for config values""" + + def __init__(self, check=True): + # Mandatory values + self.repository = os.environ.get("RESTIC_REPOSITORY") + self.password = os.environ.get("RESTIC_REPOSITORY") + self.check_with_cache = os.environ.get("CHECK_WITH_CACHE") or False + self.cron_schedule = ( + os.environ.get("CRON_SCHEDULE") or self.default_crontab_schedule + ) + self.cron_command = ( + os.environ.get("CRON_COMMAND") or self.default_backup_command + ) + self.maintenance_schedule = os.environ.get("MAINTENANCE_SCHEDULE") or "" + self.maintenance_command = ( + os.environ.get("MAINTENANCE_COMMAND") or self.default_maintenance_command + ) + self.swarm_mode = os.environ.get("SWARM_MODE") or False + self.exclude_bind_mounts = os.environ.get("EXCLUDE_BIND_MOUNTS") or False + self.include_all_compose_projects = ( + os.environ.get("INCLUDE_ALL_COMPOSE_PROJECTS") or False + ) + self.include_project_name = ( + os.environ.get("INCLUDE_ALL_COMPOSE_PROJECTS") + or os.environ.get("INCLUDE_PROJECT_NAME") + or False + ) + self.include_all_volumes = os.environ.get("INCLUDE_ALL_VOLUMES") or False + if self.include_all_volumes: + logger.warning( + "INCLUDE_ALL_VOLUMES will be deprecated in the future in favor of AUTO_BACKUP_ALL. Please update your environment variables." + ) + self.auto_backup_all = ( + os.environ.get("AUTO_BACKUP_ALL") or self.include_all_volumes + ) + self.atomic_backup = os.environ.get("ATOMIC_BACKUP") or False + + # Log + self.log_level = os.environ.get("LOG_LEVEL") + + # forget / keep + self.keep_daily = ( + os.environ.get("RESTIC_KEEP_DAILY") or os.environ.get("KEEP_DAILY") or "7" + ) + self.keep_weekly = ( + os.environ.get("RESTIC_KEEP_WEEKLY") or os.environ.get("KEEP_WEEKLY") or "4" + ) + self.keep_monthly = ( + os.environ.get("RESTIC_KEEP_MONTHLY") + or os.environ.get("KEEP_MONTHLY") + or "12" + ) + self.keep_yearly = ( + os.environ.get("RESTIC_KEEP_YEARLY") or os.environ.get("KEEP_YEARLY") or "3" + ) + + if check: + self.check() + + def check(self): + if not self.repository: + raise ValueError("RESTIC_REPOSITORY env var not set") + + if not self.password: + raise ValueError("RESTIC_REPOSITORY env var not set") + + +config = Config() diff --git a/src/restic_compose_backup/containers.py b/src/restic_compose_backup/containers.py index 55ee256..8a56989 100644 --- a/src/restic_compose_backup/containers.py +++ b/src/restic_compose_backup/containers.py @@ -1,494 +1,498 @@ -import logging -from pathlib import Path -import socket -from typing import List - -from restic_compose_backup import enums, utils -from restic_compose_backup.config import config - -logger = logging.getLogger(__name__) - -VOLUME_TYPE_BIND = "bind" -VOLUME_TYPE_VOLUME = "volume" - - -class Container: - """Represents a docker container""" - - container_type = None - - def __init__(self, data: dict): - self._data = data - self._state = data.get("State") - self._config = data.get("Config") - self._mounts = [Mount(mnt, container=self) for mnt in data.get("Mounts")] - - if not self._state: - raise ValueError("Container meta missing State") - if self._config is None: - raise ValueError("Container meta missing Config") - - self._labels = self._config.get("Labels") - if self._labels is None: - raise ValueError("Container meta missing Config->Labels") - - self._include = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_INCLUDE)) - self._exclude = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_EXCLUDE)) - - @property - def instance(self) -> "Container": - """Container: Get a service specific subclass instance""" - # TODO: Do this smarter in the future (simple registry) - if self.database_backup_enabled: - from restic_compose_backup import containers_db - - if self.mariadb_backup_enabled: - return containers_db.MariadbContainer(self._data) - if self.mysql_backup_enabled: - return containers_db.MysqlContainer(self._data) - if self.postgresql_backup_enabled: - return containers_db.PostgresContainer(self._data) - else: - return self - - @property - def id(self) -> str: - """str: The id of the container""" - return self._data.get("Id") - - @property - def image(self) -> str: - """Image name""" - return self.get_config("Image") - - @property - def name(self) -> str: - """Container name""" - return self._data["Name"].replace("/", "") - - @property - def service_name(self) -> str: - """Name of the container/service""" - return self.get_label( - "com.docker.compose.service", default="" - ) or self.get_label("com.docker.swarm.service.name", default="") - - @property - def backup_process_label(self) -> str: - """str: The unique backup process label for this project""" - return f"{enums.LABEL_BACKUP_PROCESS}-{self.project_name}" - - @property - def project_name(self) -> str: - """str: Name of the compose setup""" - return self.get_label("com.docker.compose.project", default="") - - @property - def stack_name(self) -> str: - """str: Name of the stack is present""" - return self.get_label("com.docker.stack.namespace") - - @property - def is_oneoff(self) -> bool: - """Was this container started with run command?""" - return self.get_label("com.docker.compose.oneoff", default="False") == "True" - - @property - def environment(self) -> list: - """All configured env vars for the container as a list""" - return self.get_config("Env") - - def remove(self): - self._data.remove() - - def get_config_env(self, name) -> str: - """Get a config environment variable by name""" - # convert to dict and fetch env var by name - data = {i[0 : i.find("=")]: i[i.find("=") + 1 :] for i in self.environment} - return data.get(name) - - def set_config_env(self, name, value): - """Set an environment variable""" - env = self.environment - new_value = f"{name}={value}" - for i, entry in enumerate(env): - if f"{name}=" in entry: - env[i] = new_value - break - else: - env.append(new_value) - - @property - def volumes(self) -> dict: - """ - Return volumes for the container in the following format: - {'/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'},} - """ - volumes = {} - for mount in self._mounts: - volumes[mount.source] = { - "bind": mount.destination, - "mode": "rw", - } - - return volumes - - @property - def backup_enabled(self) -> bool: - """Is backup enabled for this container?""" - return any( - [ - self.volume_backup_enabled, - self.database_backup_enabled, - ] - ) - - @property - def volume_backup_enabled(self) -> bool: - """bool: If the ``stack-back.volumes`` label is set""" - explicitly_enabled = utils.is_true(self.get_label(enums.LABEL_VOLUMES_ENABLED)) - explicitly_disabled = utils.is_false( - self.get_label(enums.LABEL_VOLUMES_ENABLED) - ) - automatically_enabled = utils.is_true(config.auto_backup_all) - return explicitly_enabled or (automatically_enabled and not explicitly_disabled) - - @property - def database_backup_enabled(self) -> bool: - """bool: Is database backup enabled in any shape or form?""" - return any( - [ - self.mysql_backup_enabled, - self.mariadb_backup_enabled, - self.postgresql_backup_enabled, - ] - ) - - @property - def mysql_backup_enabled(self) -> bool: - """bool: If the ``stack-back.mysql`` label is set""" - explicity_enabled = utils.is_true(self.get_label(enums.LABEL_MYSQL_ENABLED)) - explicity_disabled = utils.is_false(self.get_label(enums.LABEL_MYSQL_ENABLED)) - automatically_enabled = utils.is_true( - config.auto_backup_all - ) and self.image.startswith("mysql") - return explicity_enabled or (automatically_enabled and not explicity_disabled) - - @property - def mariadb_backup_enabled(self) -> bool: - """bool: If the ``stack-back.mariadb`` label is set""" - explicity_enabled = utils.is_true(self.get_label(enums.LABEL_MARIADB_ENABLED)) - explicity_disabled = utils.is_false(self.get_label(enums.LABEL_MARIADB_ENABLED)) - automatically_enabled = utils.is_true( - config.auto_backup_all - ) and self.image.startswith("mariadb") - return explicity_enabled or (automatically_enabled and not explicity_disabled) - - @property - def postgresql_backup_enabled(self) -> bool: - """bool: If the ``stack-back.postgres`` label is set""" - explicity_enabled = utils.is_true(self.get_label(enums.LABEL_POSTGRES_ENABLED)) - explicity_disabled = utils.is_false( - self.get_label(enums.LABEL_POSTGRES_ENABLED) - ) - automatically_enabled = utils.is_true( - config.auto_backup_all - ) and self.image.startswith("postgres") - return explicity_enabled or (automatically_enabled and not explicity_disabled) - - @property - def stop_during_backup(self) -> bool: - """bool: If the ``stack-back.volumes.stop-during-backup`` label is set""" - return ( - utils.is_true(self.get_label(enums.LABEL_STOP_DURING_BACKUP)) - and not self.database_backup_enabled - ) - - @property - def is_backup_process_container(self) -> bool: - """Is this container the running backup process?""" - return self.get_label(self.backup_process_label) == "True" - - @property - def is_running(self) -> bool: - """bool: Is the container running?""" - return self._state.get("Running", False) - - def get_config(self, name, default=None): - """Get value from config dict""" - return self._config.get(name, default) - - def get_label(self, name, default=None): - """Get a label by name""" - return self._labels.get(name, None) - - def filter_mounts(self): - """Get all mounts for this container matching include/exclude filters""" - filtered = [] - database_mounts = [ - "/var/lib/mysql", - "/var/lib/mariadb", - "/var/lib/postgresql/data", - ] - - # If exclude_bind_mounts is true, only volume mounts are kept in the list of mounts - exclude_bind_mounts = utils.is_true(config.exclude_bind_mounts) - mounts = list( - filter( - lambda m: not exclude_bind_mounts or m.type == "volume", self._mounts - ) - ) - - if not self.volume_backup_enabled: - return filtered - - if self._include: - for mount in mounts: - for pattern in self._include: - if pattern in mount.source: - break - else: - continue - - filtered.append(mount) - - elif self._exclude: - for mount in mounts: - for pattern in self._exclude: - if pattern in mount.source: - break - else: - filtered.append(mount) - else: - for mount in mounts: - if ( - self.database_backup_enabled - and mount.destination in database_mounts - ): - continue - filtered.append(mount) - - return filtered - - def volumes_for_backup(self, source_prefix="/volumes", mode="ro"): - """Get volumes configured for backup""" - mounts = self.filter_mounts() - volumes = {} - for mount in mounts: - volumes[mount.source] = { - "bind": self.get_volume_backup_destination(mount, source_prefix), - "mode": mode, - } - - return volumes - - def get_volume_backup_destination(self, mount, source_prefix) -> str: - """Get the destination path for backups of the given mount""" - destination = Path(source_prefix) - - if utils.is_true(config.include_project_name): - project_name = self.project_name - if project_name != "": - destination /= project_name - - destination /= self.service_name - destination /= Path(utils.strip_root(mount.destination)) - - return str(destination) - - def get_credentials(self) -> dict: - """dict: get credentials for the service""" - raise NotImplementedError("Base container class don't implement this") - - def ping(self) -> bool: - """Check the availability of the service""" - raise NotImplementedError("Base container class don't implement this") - - def backup(self): - """Back up this service""" - raise NotImplementedError("Base container class don't implement this") - - def backup_destination_path(self) -> str: - """Return the path backups will be saved at""" - raise NotImplementedError("Base container class don't implement this") - - def dump_command(self) -> list: - """list: create a dump command restic and use to send data through stdin""" - raise NotImplementedError("Base container class don't implement this") - - def _parse_pattern(self, value: str) -> List[str]: - """list: Safely parse include/exclude pattern from user""" - if not value: - return None - - if type(value) is not str: - return None - - value = value.strip() - if len(value) == 0: - return None - - return value.split(",") - - def __eq__(self, other): - """Compare container by id""" - if other is None: - return False - - if not isinstance(other, Container): - return False - - return self.id == other.id - - def __repr__(self): - return str(self) - - def __str__(self): - return "".format(self.name) - - -class Mount: - """Represents a volume mount (volume or bind)""" - - def __init__(self, data, container=None): - self._data = data - self._container = container - - @property - def container(self) -> Container: - """The container this mount belongs to""" - return self._container - - @property - def type(self) -> str: - """bind/volume""" - return self._data.get("Type") - - @property - def name(self) -> str: - """Name of the mount""" - return self._data.get("Name") - - @property - def source(self) -> str: - """Source of the mount. Volume name or path""" - return self._data.get("Source") - - @property - def destination(self) -> str: - """Destination path for the volume mount in the container""" - return self._data.get("Destination") - - def __repr__(self) -> str: - return str(self) - - def __str__(self) -> str: - return str(self._data) - - def __hash__(self): - """Uniqueness for a volume""" - if self.type == VOLUME_TYPE_VOLUME: - return hash(self.name) - elif self.type == VOLUME_TYPE_BIND: - return hash(self.source) - else: - raise ValueError("Unknown volume type: {}".format(self.type)) - - -class RunningContainers: - def __init__(self): - all_containers = utils.list_containers() - self.containers = [] - self.this_container = None - self.backup_process_container = None - self.stale_backup_process_containers = [] - self.stop_during_backup_containers = [] - - # Find the container we are running in. - # If we don't have this information we cannot continue - for container_data in all_containers: - if container_data.get("Id").startswith(socket.gethostname()): - self.this_container = Container(container_data) - - if not self.this_container: - raise ValueError("Cannot find metadata for backup container") - - # Gather relevant containers - for container_data in all_containers: - container = Container(container_data) - - # Gather stale backup process containers - if ( - self.this_container.image == container.image - and not container.is_running - and container.is_backup_process_container - ): - self.stale_backup_process_containers.append(container) - - # We only care about running containers after this point - if not container.is_running: - continue - - # If not swarm mode we need to filter in compose project - if ( - not config.swarm_mode - and not config.include_all_compose_projects - and container.project_name != self.this_container.project_name - ): - continue - - # Gather stop during backup containers - if container.stop_during_backup: - self.stop_during_backup_containers.append(container) - - # Detect running backup process container - if container.is_backup_process_container: - self.backup_process_container = container - - # Containers started manually are not included - if container.is_oneoff: - continue - - # Do not include the stack-back and backup process containers - if "stack-back" in container.image: - continue - - self.containers.append(container) - - @property - def project_name(self) -> str: - """str: Name of the compose project""" - return self.this_container.project_name - - @property - def backup_process_label(self) -> str: - """str: The backup process label for this project""" - return self.this_container.backup_process_label - - @property - def backup_process_running(self) -> bool: - """Is the backup process container running?""" - return self.backup_process_container is not None - - def containers_for_backup(self) -> list[Container]: - """Obtain all containers with backup enabled""" - return [container for container in self.containers if container.backup_enabled] - - def generate_backup_mounts(self, dest_prefix="/volumes") -> dict: - """Generate mounts for backup for the entire compose setup""" - mounts = {} - for container in self.containers_for_backup(): - if container.volume_backup_enabled: - mounts.update( - container.volumes_for_backup(source_prefix=dest_prefix, mode="ro") - ) - - return mounts - - def get_service(self, name) -> Container: - """Container: Get a service by name""" - for container in self.containers: - if container.service_name == name: - return container - - return None +import logging +from pathlib import Path +import socket +from typing import List + +from restic_compose_backup import enums, utils +from restic_compose_backup.config import config + +logger = logging.getLogger(__name__) + +VOLUME_TYPE_BIND = "bind" +VOLUME_TYPE_VOLUME = "volume" + + +class Container: + """Represents a docker container""" + + container_type = None + + def __init__(self, data: dict): + self._data = data + self._state = data.get("State") + self._config = data.get("Config") + self._mounts = [Mount(mnt, container=self) for mnt in data.get("Mounts")] + + if not self._state: + raise ValueError("Container meta missing State") + if self._config is None: + raise ValueError("Container meta missing Config") + + self._labels = self._config.get("Labels") + if self._labels is None: + raise ValueError("Container meta missing Config->Labels") + + self._include = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_INCLUDE)) + self._exclude = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_EXCLUDE)) + + @property + def instance(self) -> "Container": + """Container: Get a service specific subclass instance""" + # TODO: Do this smarter in the future (simple registry) + if self.database_backup_enabled: + from restic_compose_backup import containers_db + + if self.mariadb_backup_enabled: + return containers_db.MariadbContainer(self._data) + if self.mysql_backup_enabled: + return containers_db.MysqlContainer(self._data) + if self.postgresql_backup_enabled: + return containers_db.PostgresContainer(self._data) + else: + return self + + @property + def id(self) -> str: + """str: The id of the container""" + return self._data.get("Id") + + @property + def image(self) -> str: + """Image name""" + return self.get_config("Image") + + @property + def name(self) -> str: + """Container name""" + return self._data["Name"].replace("/", "") + + @property + def service_name(self) -> str: + """Name of the container/service""" + return self.get_label( + "com.docker.compose.service", default="" + ) or self.get_label("com.docker.swarm.service.name", default="") + + @property + def backup_process_label(self) -> str: + """str: The unique backup process label for this project""" + return f"{enums.LABEL_BACKUP_PROCESS}-{self.project_name}" + + @property + def project_name(self) -> str: + """str: Name of the compose setup""" + return self.get_label("com.docker.compose.project", default="") + + @property + def stack_name(self) -> str: + """str: Name of the stack is present""" + return self.get_label("com.docker.stack.namespace") + + @property + def is_oneoff(self) -> bool: + """Was this container started with run command?""" + return self.get_label("com.docker.compose.oneoff", default="False") == "True" + + @property + def environment(self) -> list: + """All configured env vars for the container as a list""" + return self.get_config("Env") + + def remove(self): + self._data.remove() + + def get_config_env(self, name) -> str: + """Get a config environment variable by name""" + # convert to dict and fetch env var by name + data = {i[0 : i.find("=")]: i[i.find("=") + 1 :] for i in self.environment} + return data.get(name) + + def set_config_env(self, name, value): + """Set an environment variable""" + env = self.environment + new_value = f"{name}={value}" + for i, entry in enumerate(env): + if f"{name}=" in entry: + env[i] = new_value + break + else: + env.append(new_value) + + @property + def volumes(self) -> dict: + """ + Return volumes for the container in the following format: + {'/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'},} + """ + volumes = {} + for mount in self._mounts: + volumes[mount.source] = { + "bind": mount.destination, + "mode": "rw", + } + + return volumes + + @property + def backup_enabled(self) -> bool: + """Is backup enabled for this container?""" + return any( + [ + self.volume_backup_enabled, + self.database_backup_enabled, + ] + ) + + @property + def volume_backup_enabled(self) -> bool: + """bool: If the ``stack-back.volumes`` label is set""" + explicitly_enabled = utils.is_true(self.get_label(enums.LABEL_VOLUMES_ENABLED)) + explicitly_disabled = utils.is_false( + self.get_label(enums.LABEL_VOLUMES_ENABLED) + ) + automatically_enabled = utils.is_true(config.auto_backup_all) + return explicitly_enabled or (automatically_enabled and not explicitly_disabled) + + @property + def database_backup_enabled(self) -> bool: + """bool: Is database backup enabled in any shape or form?""" + return any( + [ + self.mysql_backup_enabled, + self.mariadb_backup_enabled, + self.postgresql_backup_enabled, + ] + ) + + @property + def mysql_backup_enabled(self) -> bool: + """bool: If the ``stack-back.mysql`` label is set""" + explicity_enabled = utils.is_true(self.get_label(enums.LABEL_MYSQL_ENABLED)) + explicity_disabled = utils.is_false(self.get_label(enums.LABEL_MYSQL_ENABLED)) + automatically_enabled = utils.is_true( + config.auto_backup_all + ) and self.image.startswith("mysql") + return explicity_enabled or (automatically_enabled and not explicity_disabled) + + @property + def mariadb_backup_enabled(self) -> bool: + """bool: If the ``stack-back.mariadb`` label is set""" + explicity_enabled = utils.is_true(self.get_label(enums.LABEL_MARIADB_ENABLED)) + explicity_disabled = utils.is_false(self.get_label(enums.LABEL_MARIADB_ENABLED)) + automatically_enabled = utils.is_true( + config.auto_backup_all + ) and self.image.startswith("mariadb") + return explicity_enabled or (automatically_enabled and not explicity_disabled) + + @property + def postgresql_backup_enabled(self) -> bool: + """bool: If the ``stack-back.postgres`` label is set""" + explicity_enabled = utils.is_true(self.get_label(enums.LABEL_POSTGRES_ENABLED)) + explicity_disabled = utils.is_false( + self.get_label(enums.LABEL_POSTGRES_ENABLED) + ) + automatically_enabled = utils.is_true( + config.auto_backup_all + ) and self.image.startswith("postgres") + return explicity_enabled or (automatically_enabled and not explicity_disabled) + + @property + def stop_during_backup(self) -> bool: + """bool: If the ``stack-back.volumes.stop-during-backup`` label is set""" + return ( + utils.is_true(self.get_label(enums.LABEL_STOP_DURING_BACKUP)) + and not self.database_backup_enabled + ) + + @property + def is_backup_process_container(self) -> bool: + """Is this container the running backup process?""" + return self.get_label(self.backup_process_label) == "True" + + @property + def is_running(self) -> bool: + """bool: Is the container running?""" + return self._state.get("Running", False) + + def get_config(self, name, default=None): + """Get value from config dict""" + return self._config.get(name, default) + + def get_label(self, name, default=None): + """Get a label by name""" + return self._labels.get(name, None) + + def filter_mounts(self): + """Get all mounts for this container matching include/exclude filters""" + filtered = [] + database_mounts = [ + "/var/lib/mysql", + "/var/lib/mariadb", + "/var/lib/postgresql/data", + ] + + # If exclude_bind_mounts is true, only volume mounts are kept in the list of mounts + exclude_bind_mounts = utils.is_true(config.exclude_bind_mounts) + mounts = list( + filter( + lambda m: not exclude_bind_mounts or m.type == "volume", self._mounts + ) + ) + + if not self.volume_backup_enabled: + return filtered + + if self._include: + for mount in mounts: + for pattern in self._include: + if pattern in mount.source: + break + else: + continue + + filtered.append(mount) + + elif self._exclude: + for mount in mounts: + for pattern in self._exclude: + if pattern in mount.source: + break + else: + filtered.append(mount) + else: + for mount in mounts: + if ( + self.database_backup_enabled + and mount.destination in database_mounts + ): + continue + filtered.append(mount) + + return filtered + + def volumes_for_backup(self, source_prefix="/volumes", mode="ro"): + """Get volumes configured for backup""" + mounts = self.filter_mounts() + volumes = {} + for mount in mounts: + volumes[mount.source] = { + "bind": self.get_volume_backup_destination(mount, source_prefix), + "mode": mode, + } + + return volumes + + def get_volume_backup_destination(self, mount, source_prefix) -> str: + """Get the destination path for backups of the given mount""" + destination = Path(source_prefix) + + if utils.is_true(config.include_project_name): + project_name = self.project_name + if project_name != "": + destination /= project_name + + destination /= self.service_name + destination /= Path(utils.strip_root(mount.destination)) + + return str(destination) + + def get_credentials(self) -> dict: + """dict: get credentials for the service""" + raise NotImplementedError("Base container class don't implement this") + + def ping(self) -> bool: + """Check the availability of the service""" + raise NotImplementedError("Base container class don't implement this") + + def backup(self): + """Back up this service""" + raise NotImplementedError("Base container class don't implement this") + + def dump_to_file(self): + """Dump database to a local file for atomic backup""" + raise NotImplementedError("Base container class don't implement this") + + def backup_destination_path(self) -> str: + """Return the path backups will be saved at""" + raise NotImplementedError("Base container class don't implement this") + + def dump_command(self) -> list: + """list: create a dump command restic and use to send data through stdin""" + raise NotImplementedError("Base container class don't implement this") + + def _parse_pattern(self, value: str) -> List[str]: + """list: Safely parse include/exclude pattern from user""" + if not value: + return None + + if type(value) is not str: + return None + + value = value.strip() + if len(value) == 0: + return None + + return value.split(",") + + def __eq__(self, other): + """Compare container by id""" + if other is None: + return False + + if not isinstance(other, Container): + return False + + return self.id == other.id + + def __repr__(self): + return str(self) + + def __str__(self): + return "".format(self.name) + + +class Mount: + """Represents a volume mount (volume or bind)""" + + def __init__(self, data, container=None): + self._data = data + self._container = container + + @property + def container(self) -> Container: + """The container this mount belongs to""" + return self._container + + @property + def type(self) -> str: + """bind/volume""" + return self._data.get("Type") + + @property + def name(self) -> str: + """Name of the mount""" + return self._data.get("Name") + + @property + def source(self) -> str: + """Source of the mount. Volume name or path""" + return self._data.get("Source") + + @property + def destination(self) -> str: + """Destination path for the volume mount in the container""" + return self._data.get("Destination") + + def __repr__(self) -> str: + return str(self) + + def __str__(self) -> str: + return str(self._data) + + def __hash__(self): + """Uniqueness for a volume""" + if self.type == VOLUME_TYPE_VOLUME: + return hash(self.name) + elif self.type == VOLUME_TYPE_BIND: + return hash(self.source) + else: + raise ValueError("Unknown volume type: {}".format(self.type)) + + +class RunningContainers: + def __init__(self): + all_containers = utils.list_containers() + self.containers = [] + self.this_container = None + self.backup_process_container = None + self.stale_backup_process_containers = [] + self.stop_during_backup_containers = [] + + # Find the container we are running in. + # If we don't have this information we cannot continue + for container_data in all_containers: + if container_data.get("Id").startswith(socket.gethostname()): + self.this_container = Container(container_data) + + if not self.this_container: + raise ValueError("Cannot find metadata for backup container") + + # Gather relevant containers + for container_data in all_containers: + container = Container(container_data) + + # Gather stale backup process containers + if ( + self.this_container.image == container.image + and not container.is_running + and container.is_backup_process_container + ): + self.stale_backup_process_containers.append(container) + + # We only care about running containers after this point + if not container.is_running: + continue + + # If not swarm mode we need to filter in compose project + if ( + not config.swarm_mode + and not config.include_all_compose_projects + and container.project_name != self.this_container.project_name + ): + continue + + # Gather stop during backup containers + if container.stop_during_backup: + self.stop_during_backup_containers.append(container) + + # Detect running backup process container + if container.is_backup_process_container: + self.backup_process_container = container + + # Containers started manually are not included + if container.is_oneoff: + continue + + # Do not include the stack-back and backup process containers + if "stack-back" in container.image: + continue + + self.containers.append(container) + + @property + def project_name(self) -> str: + """str: Name of the compose project""" + return self.this_container.project_name + + @property + def backup_process_label(self) -> str: + """str: The backup process label for this project""" + return self.this_container.backup_process_label + + @property + def backup_process_running(self) -> bool: + """Is the backup process container running?""" + return self.backup_process_container is not None + + def containers_for_backup(self) -> list[Container]: + """Obtain all containers with backup enabled""" + return [container for container in self.containers if container.backup_enabled] + + def generate_backup_mounts(self, dest_prefix="/volumes") -> dict: + """Generate mounts for backup for the entire compose setup""" + mounts = {} + for container in self.containers_for_backup(): + if container.volume_backup_enabled: + mounts.update( + container.volumes_for_backup(source_prefix=dest_prefix, mode="ro") + ) + + return mounts + + def get_service(self, name) -> Container: + """Container: Get a service by name""" + for container in self.containers: + if container.service_name == name: + return container + + return None diff --git a/src/restic_compose_backup/containers_db.py b/src/restic_compose_backup/containers_db.py index 4054c6c..b143649 100644 --- a/src/restic_compose_backup/containers_db.py +++ b/src/restic_compose_backup/containers_db.py @@ -1,217 +1,269 @@ -from pathlib import Path - -from restic_compose_backup.containers import Container -from restic_compose_backup.config import config, Config -from restic_compose_backup import ( - commands, - restic, -) -from restic_compose_backup import utils - - -class MariadbContainer(Container): - container_type = "mariadb" - - def get_credentials(self) -> dict: - """dict: get credentials for the service""" - password = self.get_config_env("MARIADB_ROOT_PASSWORD") - if password is not None: - username = "root" - else: - username = self.get_config_env("MARIADB_USER") - password = self.get_config_env("MARIADB_PASSWORD") - return { - "host": "127.0.0.1", - "username": username, - "password": password, - "port": "3306", - } - - def ping(self) -> bool: - """Check the availability of the service""" - creds = self.get_credentials() - - return ( - commands.ping_mariadb( - self.id, - creds["host"], - creds["port"], - creds["username"], - creds["password"], - ) - == 0 - ) - - def dump_command(self) -> list: - """list: create a dump command restic and use to send data through stdin""" - creds = self.get_credentials() - return [ - "mariadb-dump", - f"--user={creds['username']}", - "--all-databases", - "--no-tablespaces", - "--single-transaction", - "--order-by-primary", - "--compact", - "--force", - ] - - def backup(self): - config = Config() - creds = self.get_credentials() - - return restic.backup_from_stdin( - config.repository, - self.backup_destination_path(), - self.id, - self.dump_command(), - environment={"MYSQL_PWD": creds["password"]}, - ) - - def backup_destination_path(self) -> str: - destination = Path("/databases") - - if utils.is_true(config.include_project_name): - project_name = self.project_name - if project_name != "": - destination /= project_name - - destination /= self.service_name - destination /= "all_databases.sql" - - return destination - - -class MysqlContainer(Container): - container_type = "mysql" - - def get_credentials(self) -> dict: - """dict: get credentials for the service""" - password = self.get_config_env("MYSQL_ROOT_PASSWORD") - if password is not None: - username = "root" - else: - username = self.get_config_env("MYSQL_USER") - password = self.get_config_env("MYSQL_PASSWORD") - return { - "host": "127.0.0.1", - "username": username, - "password": password, - "port": "3306", - } - - def ping(self) -> bool: - """Check the availability of the service""" - creds = self.get_credentials() - - return ( - commands.ping_mysql( - self.id, - creds["host"], - creds["port"], - creds["username"], - creds["password"], - ) - == 0 - ) - - def dump_command(self) -> list: - """list: create a dump command restic and use to send data through stdin""" - creds = self.get_credentials() - return [ - "mysqldump", - f"--user={creds['username']}", - "--all-databases", - "--no-tablespaces", - "--single-transaction", - "--order-by-primary", - "--compact", - "--force", - ] - - def backup(self): - config = Config() - creds = self.get_credentials() - - return restic.backup_from_stdin( - config.repository, - self.backup_destination_path(), - self.id, - self.dump_command(), - environment={"MYSQL_PWD": creds["password"]}, - ) - - def backup_destination_path(self) -> str: - destination = Path("/databases") - - if utils.is_true(config.include_project_name): - project_name = self.project_name - if project_name != "": - destination /= project_name - - destination /= self.service_name - destination /= "all_databases.sql" - - return destination - - -class PostgresContainer(Container): - container_type = "postgres" - - def get_credentials(self) -> dict: - """dict: get credentials for the service""" - return { - "host": "127.0.0.1", - "username": self.get_config_env("POSTGRES_USER"), - "password": self.get_config_env("POSTGRES_PASSWORD"), - "port": "5432", - "database": self.get_config_env("POSTGRES_DB"), - } - - def ping(self) -> bool: - """Check the availability of the service""" - creds = self.get_credentials() - return ( - commands.ping_postgres( - self.id, - creds["host"], - creds["port"], - creds["username"], - creds["password"], - ) - == 0 - ) - - def dump_command(self) -> list: - """list: create a dump command restic and use to send data through stdin""" - # NOTE: Backs up a single database from POSTGRES_DB env var - creds = self.get_credentials() - return [ - "pg_dump", - f"--username={creds['username']}", - creds["database"], - ] - - def backup(self): - config = Config() - creds = self.get_credentials() - - return restic.backup_from_stdin( - config.repository, - self.backup_destination_path(), - self.id, - self.dump_command(), - ) - - def backup_destination_path(self) -> str: - destination = Path("/databases") - - if utils.is_true(config.include_project_name): - project_name = self.project_name - if project_name != "": - destination /= project_name - - destination /= self.service_name - destination /= f"{self.get_credentials()['database']}.sql" - - return destination +from pathlib import Path + +from restic_compose_backup.containers import Container +from restic_compose_backup.config import config, Config +from restic_compose_backup import ( + commands, + restic, +) +from restic_compose_backup import utils + + +class MariadbContainer(Container): + container_type = "mariadb" + + def get_credentials(self) -> dict: + """dict: get credentials for the service""" + password = self.get_config_env("MARIADB_ROOT_PASSWORD") + if password is not None: + username = "root" + else: + username = self.get_config_env("MARIADB_USER") + password = self.get_config_env("MARIADB_PASSWORD") + return { + "host": "127.0.0.1", + "username": username, + "password": password, + "port": "3306", + } + + def ping(self) -> bool: + """Check the availability of the service""" + creds = self.get_credentials() + + return ( + commands.ping_mariadb( + self.id, + creds["host"], + creds["port"], + creds["username"], + creds["password"], + ) + == 0 + ) + + def dump_command(self) -> list: + """list: create a dump command restic and use to send data through stdin""" + creds = self.get_credentials() + return [ + "mariadb-dump", + f"--user={creds['username']}", + "--all-databases", + "--no-tablespaces", + "--single-transaction", + "--order-by-primary", + "--compact", + "--force", + ] + + def backup(self): + config = Config() + creds = self.get_credentials() + + return restic.backup_from_stdin( + config.repository, + self.backup_destination_path(), + self.id, + self.dump_command(), + environment={"MYSQL_PWD": creds["password"]}, + ) + + def dump_to_file(self): + """Dump database to a local file for atomic backup. + + Streams the output of ``dump_command()`` from the MariaDB container + to a file at ``backup_destination_path()`` on the backup container's + filesystem. + + Returns: + int: Exit code of the dump command (0 on success). + """ + creds = self.get_credentials() + return commands.docker_exec_to_file( + self.id, + self.dump_command(), + str(self.backup_destination_path()), + environment={"MYSQL_PWD": creds["password"]}, + ) + + def backup_destination_path(self) -> str: + destination = Path("/databases") + + if utils.is_true(config.include_project_name): + project_name = self.project_name + if project_name != "": + destination /= project_name + + destination /= self.service_name + destination /= "all_databases.sql" + + return destination + + +class MysqlContainer(Container): + container_type = "mysql" + + def get_credentials(self) -> dict: + """dict: get credentials for the service""" + password = self.get_config_env("MYSQL_ROOT_PASSWORD") + if password is not None: + username = "root" + else: + username = self.get_config_env("MYSQL_USER") + password = self.get_config_env("MYSQL_PASSWORD") + return { + "host": "127.0.0.1", + "username": username, + "password": password, + "port": "3306", + } + + def ping(self) -> bool: + """Check the availability of the service""" + creds = self.get_credentials() + + return ( + commands.ping_mysql( + self.id, + creds["host"], + creds["port"], + creds["username"], + creds["password"], + ) + == 0 + ) + + def dump_command(self) -> list: + """list: create a dump command restic and use to send data through stdin""" + creds = self.get_credentials() + return [ + "mysqldump", + f"--user={creds['username']}", + "--all-databases", + "--no-tablespaces", + "--single-transaction", + "--order-by-primary", + "--compact", + "--force", + ] + + def backup(self): + config = Config() + creds = self.get_credentials() + + return restic.backup_from_stdin( + config.repository, + self.backup_destination_path(), + self.id, + self.dump_command(), + environment={"MYSQL_PWD": creds["password"]}, + ) + + def dump_to_file(self): + """Dump database to a local file for atomic backup. + + Streams the output of ``dump_command()`` from the MySQL container + to a file at ``backup_destination_path()`` on the backup container's + filesystem. + + Returns: + int: Exit code of the dump command (0 on success). + """ + creds = self.get_credentials() + return commands.docker_exec_to_file( + self.id, + self.dump_command(), + str(self.backup_destination_path()), + environment={"MYSQL_PWD": creds["password"]}, + ) + + def backup_destination_path(self) -> str: + destination = Path("/databases") + + if utils.is_true(config.include_project_name): + project_name = self.project_name + if project_name != "": + destination /= project_name + + destination /= self.service_name + destination /= "all_databases.sql" + + return destination + + +class PostgresContainer(Container): + container_type = "postgres" + + def get_credentials(self) -> dict: + """dict: get credentials for the service""" + return { + "host": "127.0.0.1", + "username": self.get_config_env("POSTGRES_USER"), + "password": self.get_config_env("POSTGRES_PASSWORD"), + "port": "5432", + "database": self.get_config_env("POSTGRES_DB"), + } + + def ping(self) -> bool: + """Check the availability of the service""" + creds = self.get_credentials() + return ( + commands.ping_postgres( + self.id, + creds["host"], + creds["port"], + creds["username"], + creds["password"], + ) + == 0 + ) + + def dump_command(self) -> list: + """list: create a dump command restic and use to send data through stdin""" + # NOTE: Backs up a single database from POSTGRES_DB env var + creds = self.get_credentials() + return [ + "pg_dump", + f"--username={creds['username']}", + creds["database"], + ] + + def backup(self): + config = Config() + creds = self.get_credentials() + + return restic.backup_from_stdin( + config.repository, + self.backup_destination_path(), + self.id, + self.dump_command(), + ) + + def dump_to_file(self): + """Dump database to a local file for atomic backup. + + Streams the output of ``dump_command()`` from the PostgreSQL container + to a file at ``backup_destination_path()`` on the backup container's + filesystem. + + Returns: + int: Exit code of the dump command (0 on success). + """ + return commands.docker_exec_to_file( + self.id, + self.dump_command(), + str(self.backup_destination_path()), + ) + + def backup_destination_path(self) -> str: + destination = Path("/databases") + + if utils.is_true(config.include_project_name): + project_name = self.project_name + if project_name != "": + destination /= project_name + + destination /= self.service_name + destination /= f"{self.get_credentials()['database']}.sql" + + return destination diff --git a/src/restic_compose_backup/restic.py b/src/restic_compose_backup/restic.py index 77e9cb5..000db03 100644 --- a/src/restic_compose_backup/restic.py +++ b/src/restic_compose_backup/restic.py @@ -1,179 +1,192 @@ -""" -Restic commands -""" - -import logging -from typing import List, Tuple, Union -from subprocess import Popen, PIPE -from restic_compose_backup import commands, utils - -logger = logging.getLogger(__name__) - - -def init_repo(repository: str): - """ - Attempt to initialize the repository. - Doing this after the repository is initialized - """ - return commands.run( - restic( - repository, - [ - "init", - ], - ) - ) - - -def backup_files(repository: str, source="/volumes"): - return commands.run( - restic( - repository, - [ - "--verbose", - "backup", - source, - ], - ) - ) - - -def backup_from_stdin( - repository: str, - filename: str, - container_id: str, - source_command: List[str], - environment: Union[dict, list] = None, -): - """ - Backs up from stdin running the source_command passed in within the given container. - It will appear in restic with the filename (including path) passed in. - """ - dest_command = restic( - repository, - [ - "backup", - "--stdin", - "--stdin-filename", - filename, - ], - ) - - client = utils.docker_client() - - logger.debug( - f"docker exec inside container {container_id} command: {' '.join(source_command)}" - ) - - # Create and start source command inside the given container - handle = client.api.exec_create( - container_id, source_command, environment=environment - ) - exec_id = handle.get("Id") - stream = client.api.exec_start(exec_id, stream=True, demux=True) - source_stderr = "" - - # Create the restic process to receive the output of the source command - dest_process = Popen( - dest_command, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=65536 - ) - - # Send the output of the source command over to restic in the chunks received - for stdout_chunk, stderr_chunk in stream: - if stdout_chunk: - dest_process.stdin.write(stdout_chunk) - if stderr_chunk: - source_stderr += stderr_chunk.decode() - - # Wait for restic to finish - stdout, stderr = dest_process.communicate() - - # Ensure both processes exited with code 0 - source_exit = client.api.exec_inspect(exec_id).get("ExitCode") - dest_exit = dest_process.poll() - exit_code = source_exit or dest_exit - - if stdout: - commands.log_std( - "stdout", stdout, logging.DEBUG if exit_code == 0 else logging.ERROR - ) - - if source_stderr: - commands.log_std(f"stderr ({source_command[0]})", source_stderr, logging.ERROR) - - if stderr: - commands.log_std("stderr (restic)", stderr, logging.ERROR) - - return exit_code - - -def snapshots(repository: str, last=True) -> Tuple[str, str]: - """Returns the stdout and stderr info""" - args = ["snapshots"] - if last: - args.append("--latest") - args.append("1") - return commands.run_capture_std(restic(repository, args)) - - -def is_initialized(repository: str) -> bool: - """ - Checks if a repository is initialized with restic cat config. - https://restic.readthedocs.io/en/latest/075_scripting.html#check-if-a-repository-is-already-initialized - """ - response = commands.run(restic(repository, ["cat", "config"])) - if response == 0: - return True - elif response == 10: - return False - else: - logger.error("Error while checking if repository is initialized") - exit(1) - - -def forget(repository: str, daily: str, weekly: str, monthly: str, yearly: str): - return commands.run( - restic( - repository, - [ - "forget", - "--group-by", - "paths", - "--keep-daily", - daily, - "--keep-weekly", - weekly, - "--keep-monthly", - monthly, - "--keep-yearly", - yearly, - ], - ) - ) - - -def prune(repository: str): - return commands.run( - restic( - repository, - [ - "prune", - ], - ) - ) - - -def check(repository: str, with_cache: bool = False): - check_args = ["check"] - if with_cache: - check_args.append("--with-cache") - return commands.run(restic(repository, check_args)) - - -def restic(repository: str, args: List[str]): - """Generate restic command""" - return [ - "restic", - "-r", - repository, - ] + args +""" +Restic commands +""" + +import logging +from typing import List, Tuple, Union +from subprocess import Popen, PIPE +from restic_compose_backup import commands, utils + +logger = logging.getLogger(__name__) + + +def init_repo(repository: str): + """ + Attempt to initialize the repository. + Doing this after the repository is initialized + """ + return commands.run( + restic( + repository, + [ + "init", + ], + ) + ) + + +def backup_files(repository: str, source="/volumes"): + """Back up files to the repository. + + Args: + repository: Restic repository URL/path. + source: A single path string or a list of paths to include in + the backup. When a list is provided every path is passed + to ``restic backup`` so they end up in the same snapshot. + """ + if isinstance(source, (list, tuple)): + sources = list(source) + else: + sources = [source] + + return commands.run( + restic( + repository, + [ + "--verbose", + "backup", + ] + + sources, + ) + ) + + +def backup_from_stdin( + repository: str, + filename: str, + container_id: str, + source_command: List[str], + environment: Union[dict, list] = None, +): + """ + Backs up from stdin running the source_command passed in within the given container. + It will appear in restic with the filename (including path) passed in. + """ + dest_command = restic( + repository, + [ + "backup", + "--stdin", + "--stdin-filename", + filename, + ], + ) + + client = utils.docker_client() + + logger.debug( + f"docker exec inside container {container_id} command: {' '.join(source_command)}" + ) + + # Create and start source command inside the given container + handle = client.api.exec_create( + container_id, source_command, environment=environment + ) + exec_id = handle.get("Id") + stream = client.api.exec_start(exec_id, stream=True, demux=True) + source_stderr = "" + + # Create the restic process to receive the output of the source command + dest_process = Popen( + dest_command, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=65536 + ) + + # Send the output of the source command over to restic in the chunks received + for stdout_chunk, stderr_chunk in stream: + if stdout_chunk: + dest_process.stdin.write(stdout_chunk) + if stderr_chunk: + source_stderr += stderr_chunk.decode() + + # Wait for restic to finish + stdout, stderr = dest_process.communicate() + + # Ensure both processes exited with code 0 + source_exit = client.api.exec_inspect(exec_id).get("ExitCode") + dest_exit = dest_process.poll() + exit_code = source_exit or dest_exit + + if stdout: + commands.log_std( + "stdout", stdout, logging.DEBUG if exit_code == 0 else logging.ERROR + ) + + if source_stderr: + commands.log_std(f"stderr ({source_command[0]})", source_stderr, logging.ERROR) + + if stderr: + commands.log_std("stderr (restic)", stderr, logging.ERROR) + + return exit_code + + +def snapshots(repository: str, last=True) -> Tuple[str, str]: + """Returns the stdout and stderr info""" + args = ["snapshots"] + if last: + args.append("--latest") + args.append("1") + return commands.run_capture_std(restic(repository, args)) + + +def is_initialized(repository: str) -> bool: + """ + Checks if a repository is initialized with restic cat config. + https://restic.readthedocs.io/en/latest/075_scripting.html#check-if-a-repository-is-already-initialized + """ + response = commands.run(restic(repository, ["cat", "config"])) + if response == 0: + return True + elif response == 10: + return False + else: + logger.error("Error while checking if repository is initialized") + exit(1) + + +def forget(repository: str, daily: str, weekly: str, monthly: str, yearly: str): + return commands.run( + restic( + repository, + [ + "forget", + "--group-by", + "paths", + "--keep-daily", + daily, + "--keep-weekly", + weekly, + "--keep-monthly", + monthly, + "--keep-yearly", + yearly, + ], + ) + ) + + +def prune(repository: str): + return commands.run( + restic( + repository, + [ + "prune", + ], + ) + ) + + +def check(repository: str, with_cache: bool = False): + check_args = ["check"] + if with_cache: + check_args.append("--with-cache") + return commands.run(restic(repository, check_args)) + + +def restic(repository: str, args: List[str]): + """Generate restic command""" + return [ + "restic", + "-r", + repository, + ] + args diff --git a/src/tests/unit/fixtures.py b/src/tests/unit/fixtures.py index b3dd0ce..2d3443a 100644 --- a/src/tests/unit/fixtures.py +++ b/src/tests/unit/fixtures.py @@ -1,60 +1,61 @@ -"""Generate test fixtures""" - -from datetime import datetime -import hashlib -import string -import random - - -def generate_sha256(): - """Generate a unique sha256""" - h = hashlib.sha256() - h.update(str(datetime.now().timestamp()).encode()) - return h.hexdigest() - - -def containers(project="default", containers=[]): - """ - Args: - project (str): Name of the compose project - containers (dict): - { - 'containers: [ - 'id': 'something' - 'service': 'service_name', - 'image': 'image:tag', - 'mounts: [{ - 'Source': '/home/user/stuff', - 'Destination': '/srv/stuff', - 'Type': 'bind' / 'volume' - }], - ] - } - """ - - def wrapper(*args, **kwargs): - return [ - { - "Id": container.get("id", generate_sha256()), - "Name": container.get("service") - + "_" - + "".join(random.choice(string.ascii_lowercase) for i in range(16)), - "Config": { - "Image": container.get("image", "image:latest"), - "Labels": { - "com.docker.compose.oneoff": "False", - "com.docker.compose.project": project, - "com.docker.compose.service": container["service"], - **container.get("labels", {}), - }, - }, - "Mounts": container.get("mounts", []), - "State": { - "Status": "running", - "Running": True, - }, - } - for container in containers - ] - - return wrapper +"""Generate test fixtures""" + +from datetime import datetime +import hashlib +import string +import random + + +def generate_sha256(): + """Generate a unique sha256""" + h = hashlib.sha256() + h.update(str(datetime.now().timestamp()).encode()) + return h.hexdigest() + + +def containers(project="default", containers=[]): + """ + Args: + project (str): Name of the compose project + containers (dict): + { + 'containers: [ + 'id': 'something' + 'service': 'service_name', + 'image': 'image:tag', + 'mounts: [{ + 'Source': '/home/user/stuff', + 'Destination': '/srv/stuff', + 'Type': 'bind' / 'volume' + }], + ] + } + """ + + def wrapper(*args, **kwargs): + return [ + { + "Id": container.get("id", generate_sha256()), + "Name": container.get("service") + + "_" + + "".join(random.choice(string.ascii_lowercase) for i in range(16)), + "Config": { + "Image": container.get("image", "image:latest"), + "Labels": { + "com.docker.compose.oneoff": "False", + "com.docker.compose.project": project, + "com.docker.compose.service": container["service"], + **container.get("labels", {}), + }, + "Env": container.get("env", []), + }, + "Mounts": container.get("mounts", []), + "State": { + "Status": "running", + "Running": True, + }, + } + for container in containers + ] + + return wrapper diff --git a/src/tests/unit/test_atomic_backup.py b/src/tests/unit/test_atomic_backup.py new file mode 100644 index 0000000..b35e5b4 --- /dev/null +++ b/src/tests/unit/test_atomic_backup.py @@ -0,0 +1,237 @@ +"""Unit tests for atomic backup feature (ATOMIC_BACKUP env var)""" + +import os +import unittest +from unittest import mock +from pathlib import Path +import pytest + +from restic_compose_backup.config import Config +from restic_compose_backup.containers import RunningContainers +from restic_compose_backup import restic, utils +from . import fixtures +from .conftest import BaseTestCase + +pytestmark = pytest.mark.unit + +list_containers_func = "restic_compose_backup.utils.list_containers" + + +class AtomicBackupConfigTests(BaseTestCase): + """Tests for ATOMIC_BACKUP configuration""" + + def test_atomic_backup_default_false(self): + """ATOMIC_BACKUP defaults to False when not set""" + env = os.environ.copy() + env.pop("ATOMIC_BACKUP", None) + with mock.patch.dict(os.environ, env, clear=True): + config = Config(check=False) + self.assertFalse(utils.is_true(config.atomic_backup)) + + def test_atomic_backup_enabled(self): + """ATOMIC_BACKUP can be enabled via env var""" + with utils.environment("ATOMIC_BACKUP", "true"): + config = Config(check=False) + self.assertTrue(utils.is_true(config.atomic_backup)) + + def test_atomic_backup_enabled_numeric(self): + """ATOMIC_BACKUP accepts '1' as truthy""" + with utils.environment("ATOMIC_BACKUP", "1"): + config = Config(check=False) + self.assertTrue(utils.is_true(config.atomic_backup)) + + +class AtomicBackupDumpPathTests(BaseTestCase): + """Tests that dump_to_file destination paths match backup_destination_path""" + + def _make_mariadb_container(self, project="default", service="mariadb"): + """Helper to create a MariaDB container instance.""" + containers = self.createContainers() + containers.append( + { + "service": service, + "labels": { + "stack-back.mariadb": True, + }, + "mounts": [ + { + "Source": "/srv/mariadb/data", + "Destination": "/var/lib/mysql", + "Type": "bind", + }, + ], + "env": [ + "MARIADB_ROOT_PASSWORD=secret", + ], + }, + ) + with mock.patch( + list_containers_func, + fixtures.containers(project=project, containers=containers), + ): + cnt = RunningContainers() + svc = cnt.get_service(service) + return svc.instance + + def _make_mysql_container(self, project="default", service="mysql"): + """Helper to create a MySQL container instance.""" + containers = self.createContainers() + containers.append( + { + "service": service, + "labels": { + "stack-back.mysql": True, + }, + "mounts": [ + { + "Source": "/srv/mysql/data", + "Destination": "/var/lib/mysql", + "Type": "bind", + }, + ], + "env": [ + "MYSQL_ROOT_PASSWORD=secret", + ], + }, + ) + with mock.patch( + list_containers_func, + fixtures.containers(project=project, containers=containers), + ): + cnt = RunningContainers() + svc = cnt.get_service(service) + return svc.instance + + def _make_postgres_container(self, project="default", service="postgres"): + """Helper to create a PostgreSQL container instance.""" + containers = self.createContainers() + containers.append( + { + "service": service, + "labels": { + "stack-back.postgres": True, + }, + "mounts": [ + { + "Source": "/srv/postgres/data", + "Destination": "/var/lib/postgresql/data", + "Type": "bind", + }, + ], + "env": [ + "POSTGRES_USER=pguser", + "POSTGRES_PASSWORD=pgpass", + "POSTGRES_DB=mydb", + ], + }, + ) + with mock.patch( + list_containers_func, + fixtures.containers(project=project, containers=containers), + ): + cnt = RunningContainers() + svc = cnt.get_service(service) + return svc.instance + + def test_mariadb_backup_destination_path(self): + """MariaDB dump path lives under /databases/""" + instance = self._make_mariadb_container() + path = Path(str(instance.backup_destination_path())) + self.assertIn("databases", path.parts) + self.assertEqual(path.name, "all_databases.sql") + + def test_mysql_backup_destination_path(self): + """MySQL dump path lives under /databases/""" + instance = self._make_mysql_container() + path = Path(str(instance.backup_destination_path())) + self.assertIn("databases", path.parts) + self.assertEqual(path.name, "all_databases.sql") + + def test_postgres_backup_destination_path(self): + """PostgreSQL dump path lives under /databases/""" + instance = self._make_postgres_container() + path = Path(str(instance.backup_destination_path())) + self.assertIn("databases", path.parts) + self.assertTrue(path.name.endswith(".sql")) + + def test_mariadb_dump_to_file_calls_docker_exec_to_file(self): + """dump_to_file() delegates to commands.docker_exec_to_file with correct args""" + instance = self._make_mariadb_container() + with mock.patch( + "restic_compose_backup.commands.docker_exec_to_file", return_value=0 + ) as mock_exec: + result = instance.dump_to_file() + self.assertEqual(result, 0) + mock_exec.assert_called_once() + call_args = mock_exec.call_args + # Verify file_path matches backup_destination_path + self.assertEqual( + call_args[0][2], str(instance.backup_destination_path()) + ) + # Verify MYSQL_PWD is passed + self.assertIn("MYSQL_PWD", call_args[1].get("environment", {})) + + def test_mysql_dump_to_file_calls_docker_exec_to_file(self): + """dump_to_file() delegates to commands.docker_exec_to_file with correct args""" + instance = self._make_mysql_container() + with mock.patch( + "restic_compose_backup.commands.docker_exec_to_file", return_value=0 + ) as mock_exec: + result = instance.dump_to_file() + self.assertEqual(result, 0) + mock_exec.assert_called_once() + call_args = mock_exec.call_args + self.assertEqual( + call_args[0][2], str(instance.backup_destination_path()) + ) + self.assertIn("MYSQL_PWD", call_args[1].get("environment", {})) + + def test_postgres_dump_to_file_calls_docker_exec_to_file(self): + """dump_to_file() delegates to commands.docker_exec_to_file with correct args""" + instance = self._make_postgres_container() + with mock.patch( + "restic_compose_backup.commands.docker_exec_to_file", return_value=0 + ) as mock_exec: + result = instance.dump_to_file() + self.assertEqual(result, 0) + mock_exec.assert_called_once() + call_args = mock_exec.call_args + self.assertEqual( + call_args[0][2], str(instance.backup_destination_path()) + ) + + +class ResticBackupFilesTests(BaseTestCase): + """Tests for restic.backup_files accepting single and multiple sources""" + + @mock.patch("restic_compose_backup.commands.run", return_value=0) + def test_single_source_string(self, mock_run): + """backup_files with a string source passes it as a single path""" + restic.backup_files("repo", source="/volumes") + cmd = mock_run.call_args[0][0] + self.assertIn("/volumes", cmd) + # Only one source path + idx = cmd.index("backup") + sources = cmd[idx + 1 :] + self.assertEqual(sources, ["/volumes"]) + + @mock.patch("restic_compose_backup.commands.run", return_value=0) + def test_multiple_sources_list(self, mock_run): + """backup_files with a list passes all paths to restic""" + restic.backup_files("repo", source=["/volumes", "/databases"]) + cmd = mock_run.call_args[0][0] + idx = cmd.index("backup") + sources = cmd[idx + 1 :] + self.assertEqual(sources, ["/volumes", "/databases"]) + + @mock.patch("restic_compose_backup.commands.run", return_value=0) + def test_single_source_default(self, mock_run): + """backup_files default source is /volumes""" + restic.backup_files("repo") + cmd = mock_run.call_args[0][0] + idx = cmd.index("backup") + sources = cmd[idx + 1 :] + self.assertEqual(sources, ["/volumes"]) + + +