diff --git a/.gitignore b/.gitignore index b87f383..81910c0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ venv restic_data/ restic_cache/ alerts.env +minecraft/ # build build/ diff --git a/docker-compose.yaml b/docker-compose.yaml index 1edf6f8..b82b138 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -67,6 +67,17 @@ services: volumes: - pgdata:/var/lib/postgresql/data + minecraft: + image: itzg/minecraft-server + labels: + restic-compose-backup.minecraft: true + restic-compose-backup.volumes.include: "minecraft" + environment: + - RCON_PASSWORD=minecraft + - EULA=TRUE + volumes: + - ./minecraft:/data + volumes: mysqldata: mariadbdata: diff --git a/src/Dockerfile b/src/Dockerfile index 22ac9fa..bf7b5a6 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -37,8 +37,5 @@ ENV XDG_CACHE_HOME=/cache # end install -ADD backup.sh /backup.sh -RUN chmod +x /backup.sh - ENTRYPOINT [] CMD ["./entrypoint.sh"] diff --git a/src/backup.sh b/src/backup.sh deleted file mode 100644 index a8423e9..0000000 --- a/src/backup.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -if [ "${DEBUG:-false}" == "true" ]; then - set -x -fi - -: "${RCON_HOST:=localhost}" -: "${RCON_PORT:=25575}" -: "${RCON_PASSWORD:=minecraft}" - -export RCON_HOST -export RCON_PORT -export RCON_PASSWORD - -############### -## common ## -## functions ## -############### - -is_elem_in_array() { - # $1 = element - # All remaining arguments are array to search for the element in - if [ "$#" -lt 2 ]; then - log INTERNALERROR "Wrong number of arguments passed to is_elem_in_array function" - return 2 - fi - local element="${1}" - shift - local e - for e; do - if [ "${element}" == "${e}" ]; then - return 0 - fi - done - return 1 -} - -log() { - if [ "$#" -lt 1 ]; then - log INTERNALERROR "Wrong number of arguments passed to log function" - return 2 - fi - local level="${1}" - shift - local valid_levels=( - "INFO" - "WARN" - "ERROR" - "INTERNALERROR" - ) - if ! is_elem_in_array "${level}" "${valid_levels[@]}"; then - log INTERNALERROR "Log level ${level} is not a valid level." - return 2 - fi - ( - # If any arguments are passed besides log level - if [ "$#" -ge 1 ]; then - # then use them as log message(s) - <<<"${*}" cat - - else - # otherwise read log messages from standard input - cat - - fi - if [ "${level}" == "INTERNALERROR" ]; then - echo "Please report this: https://github.com/itzg/docker-mc-backup/issues" - fi - ) | awk -v level="${level}" '{ printf("%s %s %s\n", strftime("%FT%T%z"), level, $0); fflush(); }' -} >&2 - -retry() { - if [ "$#" -lt 3 ]; then - log INTERNALERROR "Wrong number of arguments passed to retry function" - return 1 - fi - - # How many times should we retry? - # Value smaller than zero means infinitely - local retries="${1}" - # Time to sleep between retries - local interval="${2}" - readonly retries interval - shift 2 - - if (( retries < 0 )); then - local retries_msg="infinite" - else - local retries_msg="${retries}" - fi - - local i=-1 # -1 since we will increment it before printing - while (( retries >= ++i )) || [ "${retries_msg}" != "${retries}" ]; do - # Send SIGINT after 5 minutes. If it doesn't shut down in 30 seconds, kill it. - if output="$(timeout --signal=SIGINT --kill-after=30s 5m "${@}" 2>&1 | tr '\n' '\t')"; then - log INFO "Command executed successfully ${*}" - return 0 - else - log ERROR "Unable to execute ${*} - try ${i}/${retries_msg}. Retrying in ${interval}" - if [ -n "${output}" ]; then - log ERROR "Failure reason: ${output}" - fi - fi - # shellcheck disable=SC2086 - sleep ${interval} - done - return 2 -} - -is_function() { - if [ "${#}" -ne 1 ]; then - log INTERNALERROR "is_function expects 1 argument, received ${#}" - fi - name="${1}" - [ "$(type -t "${name}")" == "function" ] -} - -call_if_function_exists() { - if [ "${#}" -lt 1 ]; then - log INTERNALERROR "call_if_function_exists expects at least 1 argument, received ${#}" - return 2 - fi - function_name="${1}" - if is_function "${function_name}"; then - eval "${@}" - else - log INTERNALERROR "${function_name} is not a valid function!" - return 2 - fi -} - -########## -## main ## -########## - - -log INFO "waiting for rcon readiness..." -# 20 times, 10 second delay -retry 20 10s rcon-cli save-on - - -if retry 5 10s rcon-cli save-off; then - # No matter what we were doing, from now on if the script crashes - # or gets shut down, we want to make sure saving is on - trap 'retry 5 5s rcon-cli save-on' EXIT - - retry 5 10s rcon-cli save-all - retry 5 10s sync - - rcb backup - - retry 20 10s rcon-cli save-on - # Remove our exit trap now - trap EXIT -else - log ERROR "Unable to turn saving off. Is the server running?" - exit 1 -fi \ No newline at end of file diff --git a/src/crontab b/src/crontab index 7064829..23ddd03 100644 --- a/src/crontab +++ b/src/crontab @@ -1 +1,2 @@ -10 2 * * * . /env.sh && /backup.sh > /proc/1/fd/1 2>&1 +10 2 * * * source /env.sh && rcb backup > /proc/1/fd/1 + diff --git a/src/restic_compose_backup/cli.py b/src/restic_compose_backup/cli.py index a208b9e..892f114 100644 --- a/src/restic_compose_backup/cli.py +++ b/src/restic_compose_backup/cli.py @@ -9,7 +9,7 @@ from restic_compose_backup import ( restic, ) from restic_compose_backup.config import Config -from restic_compose_backup.containers import RunningContainers +from restic_compose_backup.containers import RunningContainers, Container from restic_compose_backup import cron, utils logger = logging.getLogger(__name__) @@ -94,6 +94,21 @@ def status(config, containers): for container in backup_containers: logger.info('service: %s', container.service_name) + if container.minecraft_backup_enabled: + instance = container.instance + ping = instance.ping() + logger.info( + ' - %s (is_ready=%s):', + instance.container_type, + ping == 0 + ) + for mount in container.filter_mounts(): + logger.info( + ' - volume: %s -> %s', + mount.source, + container.get_volume_backup_destination(mount, '/minecraft'), + ) + if container.volume_backup_enabled: for mount in container.filter_mounts(): logger.info( diff --git a/src/restic_compose_backup/config.py b/src/restic_compose_backup/config.py index dda01b3..354a0a4 100644 --- a/src/restic_compose_backup/config.py +++ b/src/restic_compose_backup/config.py @@ -2,8 +2,8 @@ import os class Config: - default_backup_command = ". /env.sh && /backup.sh > /proc/1/fd/1 2>&1" - default_crontab_schedule = "0 */4 * * *" + default_backup_command = "source /env.sh && rcb backup > /proc/1/fd/1" + default_crontab_schedule = "10 2 * * *" """Bag for config values""" def __init__(self, check=True): diff --git a/src/restic_compose_backup/containers.py b/src/restic_compose_backup/containers.py index eee5703..c43c64f 100644 --- a/src/restic_compose_backup/containers.py +++ b/src/restic_compose_backup/containers.py @@ -144,6 +144,7 @@ class Container: return any([ self.volume_backup_enabled, self.database_backup_enabled, + self.minecraft_backup_enabled ]) @property @@ -206,7 +207,7 @@ class Container: 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: + if not self.volume_backup_enabled and not self.minecraft_backup_enabled: return filtered if self._include: diff --git a/src/restic_compose_backup/containers_minecraft.py b/src/restic_compose_backup/containers_minecraft.py index 24e956b..c3a68a6 100644 --- a/src/restic_compose_backup/containers_minecraft.py +++ b/src/restic_compose_backup/containers_minecraft.py @@ -1,3 +1,6 @@ +import os +import logging + from pathlib import Path from restic_compose_backup.containers import Container @@ -9,6 +12,8 @@ from restic_compose_backup import ( ) from restic_compose_backup import utils +logger = logging.getLogger(__name__) + class MinecraftContainer(Container): container_type = 'minecraft' @@ -21,16 +26,30 @@ class MinecraftContainer(Container): 'port': self.get_config_env('RCON_PORT'), } + def prepare_mc_backup(self) -> bool: + 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']) + return True + 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'] - ) + try: + 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'] + ) + except Exception as ex: + logger.error('[rcon-cli] unable to contact minecraft server %s', self.service_name) + logger.exception(ex) + return 1 def backup(self) -> bool: config = Config() @@ -40,15 +59,16 @@ class MinecraftContainer(Container): 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 + self.prepare_mc_backup() + + for mount in self.filter_mounts(): + backup_data = self.get_volume_backup_destination(mount, '/minecraft') + 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) @@ -57,13 +77,4 @@ class MinecraftContainer(Container): # 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 + return errors \ No newline at end of file diff --git a/src/restic_compose_backup/rcon.py b/src/restic_compose_backup/rcon.py index f31fab1..0dd192d 100644 --- a/src/restic_compose_backup/rcon.py +++ b/src/restic_compose_backup/rcon.py @@ -8,25 +8,26 @@ from restic_compose_backup import ( commands, containers ) +from restic_compose_backup import utils logger = logging.getLogger(__name__) def rcon_cli(host, port, cmd: str) -> int: exit_code = commands.run([ "rcon-cli", - f"--host {host}", - f"--port {port}", + 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) + raise RconException(f"rcon-cli {cmd} exited with a non-zero exit code: {exit_code}") return exit_code def is_online(host, port) -> int: """Check if rcon can be reached""" - return rcon_cli(host, port, "version") + return rcon_cli(host, port, "help") def save_off(host, port) -> int: """Turn saving off"""