feat: add minecraft backup method
Minecraft server can now be backed up using the restic-compose-backup.minecraft flag
This commit is contained in:
parent
02ae4ca6d8
commit
9eb050173f
10 changed files with 75 additions and 195 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -20,6 +20,7 @@ venv
|
||||||
restic_data/
|
restic_data/
|
||||||
restic_cache/
|
restic_cache/
|
||||||
alerts.env
|
alerts.env
|
||||||
|
minecraft/
|
||||||
|
|
||||||
# build
|
# build
|
||||||
build/
|
build/
|
||||||
|
|
|
@ -67,6 +67,17 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- 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:
|
volumes:
|
||||||
mysqldata:
|
mysqldata:
|
||||||
mariadbdata:
|
mariadbdata:
|
||||||
|
|
|
@ -37,8 +37,5 @@ ENV XDG_CACHE_HOME=/cache
|
||||||
|
|
||||||
# end install
|
# end install
|
||||||
|
|
||||||
ADD backup.sh /backup.sh
|
|
||||||
RUN chmod +x /backup.sh
|
|
||||||
|
|
||||||
ENTRYPOINT []
|
ENTRYPOINT []
|
||||||
CMD ["./entrypoint.sh"]
|
CMD ["./entrypoint.sh"]
|
||||||
|
|
158
src/backup.sh
158
src/backup.sh
|
@ -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
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ from restic_compose_backup import (
|
||||||
restic,
|
restic,
|
||||||
)
|
)
|
||||||
from restic_compose_backup.config import Config
|
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
|
from restic_compose_backup import cron, utils
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -94,6 +94,21 @@ def status(config, containers):
|
||||||
for container in backup_containers:
|
for container in backup_containers:
|
||||||
logger.info('service: %s', container.service_name)
|
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:
|
if container.volume_backup_enabled:
|
||||||
for mount in container.filter_mounts():
|
for mount in container.filter_mounts():
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
@ -2,8 +2,8 @@ import os
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
default_backup_command = ". /env.sh && /backup.sh > /proc/1/fd/1 2>&1"
|
default_backup_command = "source /env.sh && rcb backup > /proc/1/fd/1"
|
||||||
default_crontab_schedule = "0 */4 * * *"
|
default_crontab_schedule = "10 2 * * *"
|
||||||
|
|
||||||
"""Bag for config values"""
|
"""Bag for config values"""
|
||||||
def __init__(self, check=True):
|
def __init__(self, check=True):
|
||||||
|
|
|
@ -144,6 +144,7 @@ class Container:
|
||||||
return any([
|
return any([
|
||||||
self.volume_backup_enabled,
|
self.volume_backup_enabled,
|
||||||
self.database_backup_enabled,
|
self.database_backup_enabled,
|
||||||
|
self.minecraft_backup_enabled
|
||||||
])
|
])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -206,7 +207,7 @@ class Container:
|
||||||
exclude_bind_mounts = utils.is_true(config.exclude_bind_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))
|
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
|
return filtered
|
||||||
|
|
||||||
if self._include:
|
if self._include:
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from restic_compose_backup.containers import Container
|
from restic_compose_backup.containers import Container
|
||||||
|
@ -9,6 +12,8 @@ from restic_compose_backup import (
|
||||||
)
|
)
|
||||||
from restic_compose_backup import utils
|
from restic_compose_backup import utils
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MinecraftContainer(Container):
|
class MinecraftContainer(Container):
|
||||||
container_type = 'minecraft'
|
container_type = 'minecraft'
|
||||||
|
@ -21,16 +26,30 @@ class MinecraftContainer(Container):
|
||||||
'port': self.get_config_env('RCON_PORT'),
|
'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:
|
def ping(self) -> bool:
|
||||||
"""Check the availability of the service"""
|
"""Check the availability of the service"""
|
||||||
creds = self.get_credentials()
|
creds = self.get_credentials()
|
||||||
|
|
||||||
|
try:
|
||||||
logger.debug("[rcon-cli] checking if minecraft server %s is online...", self.service_name)
|
logger.debug("[rcon-cli] checking if minecraft server %s is online...", self.service_name)
|
||||||
with utils.environment('RCON_PASSWORD', creds['password']):
|
with utils.environment('RCON_PASSWORD', creds['password']):
|
||||||
return rcon.is_online(
|
return rcon.is_online(
|
||||||
creds['host'],
|
creds['host'],
|
||||||
creds['port']
|
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:
|
def backup(self) -> bool:
|
||||||
config = Config()
|
config = Config()
|
||||||
|
@ -40,9 +59,10 @@ class MinecraftContainer(Container):
|
||||||
with utils.environment('RCON_PASSWORD', creds['password']):
|
with utils.environment('RCON_PASSWORD', creds['password']):
|
||||||
try:
|
try:
|
||||||
# turn off auto-save and sync all data to the disk before backing up worlds
|
# turn off auto-save and sync all data to the disk before backing up worlds
|
||||||
prepare_mc_backup()
|
self.prepare_mc_backup()
|
||||||
for mount in container.filter_mounts():
|
|
||||||
backup_data = container.get_volume_backup_destination(mount, '/volumes')
|
for mount in self.filter_mounts():
|
||||||
|
backup_data = self.get_volume_backup_destination(mount, '/minecraft')
|
||||||
logger.info('Backing up %s', mount.source)
|
logger.info('Backing up %s', mount.source)
|
||||||
vol_result = restic.backup_files(config.repository, source=backup_data)
|
vol_result = restic.backup_files(config.repository, source=backup_data)
|
||||||
logger.debug('Minecraft backup exit code: %s', vol_result)
|
logger.debug('Minecraft backup exit code: %s', vol_result)
|
||||||
|
@ -58,12 +78,3 @@ class MinecraftContainer(Container):
|
||||||
rcon.save_on(creds['host'], creds['port'])
|
rcon.save_on(creds['host'], creds['port'])
|
||||||
|
|
||||||
return errors
|
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'])
|
|
|
@ -8,25 +8,26 @@ from restic_compose_backup import (
|
||||||
commands,
|
commands,
|
||||||
containers
|
containers
|
||||||
)
|
)
|
||||||
|
from restic_compose_backup import utils
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def rcon_cli(host, port, cmd: str) -> int:
|
def rcon_cli(host, port, cmd: str) -> int:
|
||||||
exit_code = commands.run([
|
exit_code = commands.run([
|
||||||
"rcon-cli",
|
"rcon-cli",
|
||||||
f"--host {host}",
|
f"--host={host}",
|
||||||
f"--port {port}",
|
f"--port={port}",
|
||||||
cmd
|
cmd
|
||||||
])
|
])
|
||||||
|
|
||||||
if exit_code != 0:
|
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
|
return exit_code
|
||||||
|
|
||||||
def is_online(host, port) -> int:
|
def is_online(host, port) -> int:
|
||||||
"""Check if rcon can be reached"""
|
"""Check if rcon can be reached"""
|
||||||
return rcon_cli(host, port, "version")
|
return rcon_cli(host, port, "help")
|
||||||
|
|
||||||
def save_off(host, port) -> int:
|
def save_off(host, port) -> int:
|
||||||
"""Turn saving off"""
|
"""Turn saving off"""
|
||||||
|
|
Loading…
Add table
Reference in a new issue