diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..804518f --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,549 @@ +#!/usr/bin/env bash + +SCRIPT_NAME=$(basename "$0") + +log_fatal() { + echo "$SCRIPT_NAME: $*" >&2 + exit 1 +} + +usage() { + cat< + $SCRIPT_NAME help + +Commands: + list List available backups + restore Restore a backup + help Show this usage message + +Restore options: + --all + --bluemap + --fabric + --fabric-essentials + --fabric-g4mespeed + --fabric-world + --luckperms + --paper + --paper-multiverse-inventories + --paper-essentials + --paper-plot-squared + --paper-worlds + --paper-creative + --paper-creative-overworld + --paper-creative-nether + --paper-survival + --paper-survival-overworld + --paper-survival-nether + --paper-survival-the-end + --schematics + --velocity + --velocity-dclink + +Examples: + $SCRIPT_NAME list + $SCRIPT_NAME restore --fabric 2026-01-20T07-00-49Z + $SCRIPT_NAME restore --all latest +EOF +} + +cmd_list() { + docker compose run --rm init ls -1 /backups || log_fatal "failed to list backups" +} + +cmd_restore() { + # Cleanup Flags + CLEANUP_VOLUMES=0 + + # Default flags + ## Bluemap + RESTORE_BLUEMAP=0 + ## Fabric server + RESTORE_FABRIC_ESSENTIALS=0 + RESTORE_FABRIC_G4MESPEED=0 + RESTORE_FABRIC_WORLD=0 + ## LuckPerms + RESTORE_LUCKPERMS=0 + ## Paper server + RESTORE_PAPER_MULTIVERSE_INVENTORIES=0 + RESTORE_PAPER_ESSENTIALS=0 + RESTORE_PAPER_PLOT_SQUARED=0 + RESTORE_PAPER_CREATIVE_OVERWORLD=0 + RESTORE_PAPER_CREATIVE_NETHER=0 + RESTORE_PAPER_SURVIVAL_OVERWORLD=0 + RESTORE_PAPER_SURVIVAL_NETHER=0 + RESTORE_PAPER_SURVIVAL_THE_END=0 + ## Schematics + RESTORE_SCHEMATICS=0 + ## Velocity proxy + RESTORE_VELOCITY_DCLINK=0 + + # Argument parsing + POS_ARGS="" + while [ $# -gt 0 ]; do + case "$1" in + --all) + RESTORE_BLUEMAP=1 + RESTORE_FABRIC_ESSENTIALS=1 + RESTORE_FABRIC_G4MESPEED=1 + RESTORE_FABRIC_WORLD=1 + RESTORE_LUCKPERMS=1 + RESTORE_PAPER_MULTIVERSE_INVENTORIES=1 + RESTORE_PAPER_ESSENTIALS=1 + RESTORE_PAPER_PLOT_SQUARED=1 + RESTORE_PAPER_CREATIVE_OVERWORLD=1 + RESTORE_PAPER_CREATIVE_NETHER=1 + RESTORE_PAPER_SURVIVAL_OVERWORLD=1 + RESTORE_PAPER_SURVIVAL_NETHER=1 + RESTORE_PAPER_SURVIVAL_THE_END=1 + RESTORE_SCHEMATICS=1 + RESTORE_VELOCITY_DCLINK=1 + ;; + + --bluemap) + RESTORE_BLUEMAP=1 + ;; + + --fabric) + RESTORE_FABRIC_ESSENTIALS=1 + RESTORE_FABRIC_G4MESPEED=1 + RESTORE_FABRIC_WORLD=1 + ;; + + --fabric-essentials) + RESTORE_FABRIC_ESSENTIALS=1 + ;; + + --fabric-g4mespeed) + RESTORE_FABRIC_G4MESPEED=1 + ;; + + --fabric-world) + RESTORE_FABRIC_WORLD=1 + ;; + + --luckperms) + RESTORE_LUCKPERMS=1 + ;; + + --paper) + RESTORE_PAPER_MULTIVERSE_INVENTORIES=1 + RESTORE_PAPER_ESSENTIALS=1 + RESTORE_PAPER_PLOT_SQUARED=1 + RESTORE_PAPER_CREATIVE_OVERWORLD=1 + RESTORE_PAPER_CREATIVE_NETHER=1 + RESTORE_PAPER_SURVIVAL_OVERWORLD=1 + RESTORE_PAPER_SURVIVAL_NETHER=1 + RESTORE_PAPER_SURVIVAL_THE_END=1 + ;; + + --paper-multiverse-inventories) + RESTORE_PAPER_MULTIVERSE_INVENTORIES=1 + ;; + + --paper-essentials) + RESTORE_PAPER_ESSENTIALS=1 + ;; + + --paper-plot-squared) + RESTORE_PAPER_PLOT_SQUARED=1 + ;; + + --paper-worlds) + RESTORE_PAPER_CREATIVE_OVERWORLD=1 + RESTORE_PAPER_CREATIVE_NETHER=1 + RESTORE_PAPER_SURVIVAL_OVERWORLD=1 + RESTORE_PAPER_SURVIVAL_NETHER=1 + RESTORE_PAPER_SURVIVAL_THE_END=1 + ;; + + --paper-creative) + RESTORE_PAPER_CREATIVE_OVERWORLD=1 + RESTORE_PAPER_CREATIVE_NETHER=1 + ;; + + --paper-creative-overworld) + RESTORE_PAPER_CREATIVE_OVERWORLD=1 + ;; + + --paper-creative-nether) + RESTORE_PAPER_CREATIVE_NETHER=1 + ;; + + --paper-survival) + RESTORE_PAPER_SURVIVAL_OVERWORLD=1 + RESTORE_PAPER_SURVIVAL_NETHER=1 + RESTORE_PAPER_SURVIVAL_THE_END=1 + ;; + + --paper-survival-overworld) + RESTORE_PAPER_SURVIVAL_OVERWORLD=1 + ;; + + --paper-survival-nether) + RESTORE_PAPER_SURVIVAL_NETHER=1 + ;; + + --paper-survival-the-end) + RESTORE_PAPER_SURVIVAL_THE_END=1 + ;; + + --schematics) + RESTORE_SCHEMATICS=1 + ;; + + --velocity) + RESTORE_VELOCITY_DCLINK=1 + ;; + + --velocity-dclink) + RESTORE_VELOCITY_DCLINK=1 + ;; + + --help) + usage + exit 0 + ;; + + *) + POS_ARGS="$POS_ARGS \"$1\"" + ;; + esac + shift + done + + # Restore positional arguments + set -- $POS_ARGS + + # Validate and resolve backup id + [ $# -ge 1 ] || log_fatal "missing backup id" + [ $# -le 1 ] || log_fatal "unexpected argument: $2" + BACKUP_ID="$1" + BACKUP_DIR=$(docker compose run --rm init sh -c ' + TARGET=/backups/"$1" + if [ ! -e "$TARGET" ]; then + exit 1 + fi + # Resolve symlinks to real paths + REALPATH=$(readlink -f "$TARGET") + echo "$REALPATH" + ' -- "$BACKUP_ID") || log_fatal "backup '$BACKUP_ID' not found" + # Update BACKUP_ID to the actual directory name + BACKUP_ID=$(basename "$BACKUP_DIR") + + # Validate options + if [ "$RESTORE_BLUEMAP" -eq 0 ] \ + && [ "$RESTORE_FABRIC_ESSENTIALS" -eq 0 ] \ + && [ "$RESTORE_FABRIC_G4MESPEED" -eq 0 ] \ + && [ "$RESTORE_FABRIC_WORLD" -eq 0 ] \ + && [ "$RESTORE_LUCKPERMS" -eq 0 ] \ + && [ "$RESTORE_PAPER_MULTIVERSE_INVENTORIES" -eq 0 ] \ + && [ "$RESTORE_PAPER_ESSENTIALS" -eq 0 ] \ + && [ "$RESTORE_PAPER_PLOT_SQUARED" -eq 0 ] \ + && [ "$RESTORE_PAPER_CREATIVE_OVERWORLD" -eq 0 ] \ + && [ "$RESTORE_PAPER_CREATIVE_NETHER" -eq 0 ] \ + && [ "$RESTORE_PAPER_SURVIVAL_OVERWORLD" -eq 0 ] \ + && [ "$RESTORE_PAPER_SURVIVAL_NETHER" -eq 0 ] \ + && [ "$RESTORE_PAPER_SURVIVAL_THE_END" -eq 0 ] \ + && [ "$RESTORE_SCHEMATICS" -eq 0 ] \ + && [ "$RESTORE_VELOCITY_DCLINK" -eq 0 ]; then + log_fatal "no restore options specified" + fi + + echo "=== Restore started at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" + echo "Restoring from backup: $BACKUP_DIR" + + # Ensure stack is not running + docker compose down + + # Create snapshot to revert to on failure + trap restore_cleanup_trap HUP INT QUIT ABRT TERM + create_snapshot || restore_cleanup_failure + + restore_bluemap || restore_cleanup_failure + restore_fabric || restore_cleanup_failure + restore_luckperms || restore_cleanup_failure + restore_paper || restore_cleanup_failure + restore_schematics || restore_cleanup_failure + restore_velocity || restore_cleanup_failure + + restore_cleanup_success + + echo "=== Restore completed successfully at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" +} + +create_snapshot() { + echo "Creating snapshot..." + + # Map: source volume name -> cloned volume name + # Source names must match the mount points inside init + declare -Ag VOLUMES=( + [bluemap_data]="bluemap_data_$BACKUP_ID" + [bluemap_maps]="bluemap_maps_$BACKUP_ID" + [bluemap_web]="bluemap_web_$BACKUP_ID" + [fabric_data]="fabric_data_$BACKUP_ID" + [luckperms_data]="luckperms_data_$BACKUP_ID" + [paper_data]="paper_data_$BACKUP_ID" + [schematics]="schematics_$BACKUP_ID" + [velocity_data]="velocity_data_$BACKUP_ID" + ) + + MOUNTS=() + for VOLUME in "${!VOLUMES[@]}"; do + MOUNTS+=(-v "${VOLUMES[$VOLUME]}:/backup_$VOLUME") + done + + # Volumes have started being created + # Cleanup should delete volumes + CLEANUP_VOLUMES=1 + + for VOLUME in "${VOLUMES[@]}"; do + docker volume create "$VOLUME" > /dev/null || return 1 + done + + docker compose run --rm \ + "${MOUNTS[@]}" \ + init sh -ec ' + for DIR in \ + bluemap_data \ + bluemap_maps \ + bluemap_web \ + fabric_data \ + luckperms_data \ + paper_data \ + schematics \ + velocity_data + do + cp -a --reflink=auto "/$DIR/." "/backup_$DIR" + done + ' || return 1 + + # Volumes have been successfully cloned + # Cleanup should restore contents to originals, then delete clones + CLEANUP_VOLUMES=2 + + echo "Created snapshot successfully" +} + +restore_bluemap() { + if [ "$RESTORE_BLUEMAP" -eq "1" ]; then + echo "Restoring BlueMap data..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/bluemap_data/. /bluemap_data && + cp -a --reflink=auto "$1"/bluemap_maps/. /bluemap_maps && + cp -a --reflink=auto "$1"/bluemap_web/. /bluemap_web + ' -- "$BACKUP_DIR" || return 1 + fi +} + +restore_fabric() { + if [ "$RESTORE_FABRIC_ESSENTIALS" -eq 1 ]; then + echo "Restoring Fabric server's Essentials mod data..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/fabric_data/fabric-essentials.json /fabric_data + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_FABRIC_G4MESPEED" -eq 1 ]; then + echo "Restoring Fabric server's G4mespeed mod data..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/fabric_data/g4mespeed /fabric_data + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_FABRIC_WORLD" -eq 1 ]; then + echo "Restoring Fabric server's world..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/fabric_data/world /fabric_data + ' -- "$BACKUP_DIR" || return 1 + fi +} + +restore_luckperms() { + if [ "$RESTORE_LUCKPERMS" -eq 1 ]; then + echo "Restoring LuckPerms database..." + + docker compose up --wait luckperms > /dev/null 2>&1 + + docker compose exec -T luckperms sh -c ' + psql -U luckperms postgres -c "DROP DATABASE IF EXISTS luckperms;" > /dev/null 2>&1 && + psql -U luckperms postgres -c "CREATE DATABASE luckperms OWNER luckperms;" > /dev/null 2>&1 && + psql -U luckperms luckperms < "$1"/luckperms.sql > /dev/null 2>&1 + ' -- "$BACKUP_DIR" || return 1 + fi +} + +restore_paper() { + if [ "$RESTORE_PAPER_MULTIVERSE_INVENTORIES" -eq 1 ]; then + echo "Restoring Paper server's Multiverse-Inventories plugin data..." + docker compose run --rm init sh -c ' + mkdir -p /paper_data/plugins && + chown minecraft_server:minecraft_server /paper_data/plugins && + cp -a --reflink=auto "$1"/paper_data/plugins/Multiverse-Inventories /paper_data/plugins + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_PAPER_ESSENTIALS" -eq 1 ]; then + echo "Restoring Paper server's Essentials plugin data..." + docker compose run --rm init sh -c ' + mkdir -p /paper_data/plugins && + chown minecraft_server:minecraft_server /paper_data/plugins && + cp -a --reflink=auto "$1"/paper_data/plugins/Essentials /paper_data/plugins + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_PAPER_PLOT_SQUARED" -eq 1 ]; then + echo "Restoring Paper server's PlotSquared plugin data..." + docker compose run --rm init sh -c ' + mkdir -p /paper_data/plugins && + chown minecraft_server:minecraft_server /paper_data/plugins && + cp -a --reflink=auto "$1"/paper_data/plugins/PlotSquared /paper_data/plugins + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_PAPER_CREATIVE_OVERWORLD" -eq 1 ]; then + echo "Restoring Paper server's creative overworld..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/paper_data/creative /paper_data + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_PAPER_CREATIVE_NETHER" -eq 1 ]; then + echo "Restoring Paper server's creative nether..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/paper_data/creative_nether /paper_data + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_PAPER_SURVIVAL_OVERWORLD" -eq 1 ]; then + echo "Restoring Paper server's survival overworld..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/paper_data/survival /paper_data + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_PAPER_SURVIVAL_NETHER" -eq 1 ]; then + echo "Restoring Paper server's survival nether..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/paper_data/survival_nether /paper_data + ' -- "$BACKUP_DIR" || return 1 + fi + + if [ "$RESTORE_PAPER_SURVIVAL_THE_END" -eq 1 ]; then + echo "Restoring Paper server's survival the end..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/paper_data/survival_the_end /paper_data + ' -- "$BACKUP_DIR" || return 1 + fi +} + +restore_schematics() { + if [ "$RESTORE_SCHEMATICS" -eq 1 ]; then + echo "Restoring WorldEdit schematics..." + docker compose run --rm init sh -c ' + cp -a --reflink=auto "$1"/schematics/. /schematics + ' -- "$BACKUP_DIR" || return 1 + fi +} + +restore_velocity() { + if [ "$RESTORE_VELOCITY_DCLINK" -eq 1 ]; then + echo "Restoring Velocity proxy's dclink database..." + docker compose run --rm init sh -c ' + mkdir -p /velocity_data/plugins/dclink-velocity && + chown minecraft_server:minecraft_server /velocity_data/plugins && + chown minecraft_server:minecraft_server /velocity_data/plugins/dclink-velocity && + cp -a --reflink=auto "$1"/velocity_data/plugins/dclink-velocity/dclink.db /velocity_data/plugins/dclink-velocity + ' -- "$BACKUP_DIR" || return 1 + fi +} + +restore_cleanup_success() { + CLEANUP_VOLUMES=1 + restore_cleanup +} + +restore_cleanup_trap() { + restore_cleanup + echo "=== Restore cancelled at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" + exit 0 +} + +restore_cleanup_failure() { + restore_cleanup + echo "=== Restore completed unsuccessfully at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" + exit 1 +} + +restore_cleanup() { + # Ignore specified conditions to ensure cleanup runs to completion uninterrupted + trap '' HUP INT QUIT ABRT TERM + + echo "Running cleanup..." + + docker compose down > /dev/null 2>&1 + + if [ "$CLEANUP_VOLUMES" -ge "2" ]; then + echo "Reverting to snapshot..." + docker compose run --rm \ + "${MOUNTS[@]}" \ + init sh -ec ' + for DIR in \ + bluemap_data \ + bluemap_maps \ + bluemap_web \ + fabric_data \ + luckperms_data \ + paper_data \ + schematics \ + velocity_data + do + find "${DIR:?}" -mindepth 1 -delete && + cp -a --reflink=auto "/backup_$DIR/." "/$DIR" + done + ' || return 1 + echo "Reverted to snapshot successfully" + fi + + if [ "$CLEANUP_VOLUMES" -ge "1" ]; then + echo "Removing snapshot..." + for VOLUME in "${VOLUMES[@]}"; do + docker volume rm "$VOLUME" > /dev/null 2>&1 + done + echo "Removed snapshot successfully" + fi + + echo "Finished cleanup successfully" +} + +if [ "$EUID" -ne 0 ]; then + log_fatal "This command must be run by the root user" +fi + +[ $# -ge 1 ] || { + usage + log_fatal "expected argument" +} + +COMMAND=$1 +shift +case "$COMMAND" in + list) + cmd_list "$@" + ;; + + restore) + cmd_restore "$@" + ;; + + help) + usage + ;; + + *) + log_fatal "unknown command: $COMMAND" + ;; +esac