diff --git a/src/restic_compose_backup/cli.py b/src/restic_compose_backup/cli.py index d39a5c8..a208b9e 100644 --- a/src/restic_compose_backup/cli.py +++ b/src/restic_compose_backup/cli.py @@ -142,6 +142,8 @@ def backup(config, containers): # Map volumes from other containers we are backing up mounts = containers.generate_backup_mounts('/volumes') volumes.update(mounts) + mounts = containers.generate_minecraft_mounts('/minecraft') + volumes.update(mounts) logger.debug('Starting backup container with image %s', containers.this_container.image) try: @@ -208,25 +210,41 @@ def start_backup_process(config, containers): logger.warning("Found no volumes to back up") has_volumes = False + try: + has_minecraft_volumes = os.stat('/minecraft') is not None + except FileNotFoundError: + logger.warning("Found no minecraft servers to back up") + has_minecraft_volumes = False + # Warn if there is nothing to do - if len(containers.containers_for_backup()) == 0 and not has_volumes: + backup_containers = containers.containers_for_backup() + if len(backup_containers) == 0 and not has_volumes: logger.error("No containers for backup found") exit(1) if has_volumes: - logger.info('Backing up volumes') - for volume in [f for f in os.scandir('/volumes') if f.is_dir()]: - logger.info('Backing up volumes of %s', volume.name) - for path in [f.path for f in os.scandir(volume.path) if f.is_dir()]: + 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 + + if has_minecraft_volumes: + logger.info('Backing up minecraft servers') + for container in containers.containers_for_backup(): + if container.minecraft_backup_enabled: try: - logger.info('Backing up %s', path) - vol_result = restic.backup_files(config.repository, source=path) - 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) + result = backup_container_instance(container) + if result != 0: + logger.error('Backup command exited with non-zero code: %s', result) errors = True except Exception as ex: - logger.error('Exception raised during volume backup') logger.exception(ex) errors = True @@ -235,10 +253,7 @@ def start_backup_process(config, containers): for container in containers.containers_for_backup(): if container.database_backup_enabled: try: - instance = container.instance - logger.info('Backing up %s in service %s', instance.container_type, instance.service_name) - result = instance.backup() - logger.debug('Exit code: %s', result) + result = backup_container_instance(container) if result != 0: logger.error('Backup command exited with non-zero code: %s', result) errors = True @@ -267,6 +282,12 @@ def start_backup_process(config, containers): logger.info('Backup completed') +def backup_container_instance(container: Container) -> int: + instance = container.instance + logger.info('Backing up %s in service %s', instance.container_type, instance.service_name) + result = instance.backup() + logger.debug('Exit code: %s', result) + return result def cleanup(config, containers): """Run forget / prune to minimize storage space""" diff --git a/src/restic_compose_backup/containers.py b/src/restic_compose_backup/containers.py index 8ad0f97..eee5703 100644 --- a/src/restic_compose_backup/containers.py +++ b/src/restic_compose_backup/containers.py @@ -3,7 +3,7 @@ import logging from pathlib import Path from typing import List -from restic_compose_backup import enums, utils +from restic_compose_backup import enums, utils, rcon from restic_compose_backup.config import config logger = logging.getLogger(__name__) @@ -46,6 +46,9 @@ class Container: return containers_db.MysqlContainer(self._data) if self.postgresql_backup_enabled: return containers_db.PostgresContainer(self._data) + elif self.minecraft_backup_enabled: + from restic_compose_backup import containers_minecraft + return containers_minecraft.MinecraftContainer(self._data) else: return self @@ -148,6 +151,11 @@ class Container: """bool: If the ``restic-compose-backup.volumes`` label is set""" return utils.is_true(self.get_label(enums.LABEL_VOLUMES_ENABLED)) + @property + def minecraft_backup_enabled(self) -> bool: + """bool: If the ``restic-compose-backup.minecraft``` label is set""" + return utils.is_true(self.get_label(enums.LABEL_MINECRAFT_ENABLED)) + @property def database_backup_enabled(self) -> bool: """bool: Is database backup enabled in any shape or form?""" @@ -428,6 +436,15 @@ class RunningContainers: return mounts + def generate_minecraft_mounts(self, dest_prefix='/minecraft') -> dict: + """Generate minecraft mounts for backup for the entire compose setup""" + mounts = {} + for container in self.containers_for_backup(): + if container.minecraft_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: diff --git a/src/restic_compose_backup/containers_minecraft.py b/src/restic_compose_backup/containers_minecraft.py new file mode 100644 index 0000000..24e956b --- /dev/null +++ b/src/restic_compose_backup/containers_minecraft.py @@ -0,0 +1,69 @@ +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, + rcon +) +from restic_compose_backup import utils + + +class MinecraftContainer(Container): + container_type = 'minecraft' + + def get_credentials(self) -> dict: + """dict: get credentials for the service""" + return { + 'host': self.hostname, + 'password': self.get_config_env('RCON_PASSWORD'), + 'port': self.get_config_env('RCON_PORT'), + } + + def ping(self) -> bool: + """Check the availability of the service""" + creds = self.get_credentials() + + logger.debug("[rcon-cli] checking if minecraft server %s is online...", self.service_name) + with utils.environment('RCON_PASSWORD', creds['password']): + return rcon.is_online( + creds['host'], + creds['port'] + ) + + def backup(self) -> bool: + config = Config() + creds = self.get_credentials() + + errors = False + with utils.environment('RCON_PASSWORD', creds['password']): + try: + # turn off auto-save and sync all data to the disk before backing up worlds + prepare_mc_backup() + for mount in container.filter_mounts(): + backup_data = container.get_volume_backup_destination(mount, '/volumes') + logger.info('Backing up %s', mount.source) + vol_result = restic.backup_files(config.repository, source=backup_data) + logger.debug('Minecraft backup exit code: %s', vol_result) + if vol_result != 0: + logger.error('Minecraft backup exited with non-zero code: %s', vol_result) + errors = True + except Exception as ex: + logger.error('Exception raised during minecraft backup') + logger.exception(ex) + errors = True + + # always always turn saving back on + rcon.save_on(creds['host'], creds['port']) + + return errors + + + def prepare_mc_backup(): + creds = self.get_credentials() + + with utils.environment('RCON_PASSWORD', creds['password']): + rcon.save_off(creds['host'], creds['port']) + rcon.save_all(creds['host'], creds['port']) + rcon.sync(creds['host'], creds['port']) \ No newline at end of file diff --git a/src/restic_compose_backup/enums.py b/src/restic_compose_backup/enums.py index ac557d0..b32b0ea 100644 --- a/src/restic_compose_backup/enums.py +++ b/src/restic_compose_backup/enums.py @@ -9,3 +9,5 @@ LABEL_POSTGRES_ENABLED = 'restic-compose-backup.postgres' LABEL_MARIADB_ENABLED = 'restic-compose-backup.mariadb' LABEL_BACKUP_PROCESS = 'restic-compose-backup.process' + +LABEL_MINECRAFT_ENABLED = 'restic-compose-backup.minecraft' \ No newline at end of file diff --git a/src/restic_compose_backup/rcon.py b/src/restic_compose_backup/rcon.py new file mode 100644 index 0000000..f31fab1 --- /dev/null +++ b/src/restic_compose_backup/rcon.py @@ -0,0 +1,53 @@ +""" +rcon-cli commands +""" +import logging +from typing import List, Tuple +from subprocess import Popen, PIPE +from restic_compose_backup import ( + commands, + containers +) + +logger = logging.getLogger(__name__) + +def rcon_cli(host, port, cmd: str) -> int: + exit_code = commands.run([ + "rcon-cli", + f"--host {host}", + f"--port {port}", + cmd + ]) + + if exit_code != 0: + raise RconException("rcon-cli %s exited with a non-zero exit code: %s", cmd, exit_code) + + return exit_code + +def is_online(host, port) -> int: + """Check if rcon can be reached""" + return rcon_cli(host, port, "version") + +def save_off(host, port) -> int: + """Turn saving off""" + return rcon_cli(host, port, "save-off") + +def save_on(host, port) -> int: + """Turn saving on""" + return rcon_cli(host, port, "save-on") + +def save_all(host, port) -> int: + """Save all worlds""" + return rcon_cli(host, port, "save-all") + + +def sync(host, port) -> int: + """sync data""" + return rcon_cli(host, port, "sync") + + +class RconException(Exception): + """Raised when an error occured while using the rcon-cli""" + + def __init__(self, message): + self.message = message