From 2ec8f7f4dc550ee6980be293400ac493e2962e23 Mon Sep 17 00:00:00 2001 From: Nicolas Chan Date: Sat, 21 Mar 2020 17:33:21 -0700 Subject: [PATCH] Major refactor to support new backends --- backup.sh | 269 ++++--------------------------------- src/backup-methods/tar.sh | 195 +++++++++++++++++++++++++++ src/core.sh | 185 +++++++++++++++++++++++++ src/exec-methods/screen.sh | 21 +++ src/logging.sh | 46 +++++++ 5 files changed, 473 insertions(+), 243 deletions(-) create mode 100755 src/backup-methods/tar.sh create mode 100755 src/core.sh create mode 100755 src/exec-methods/screen.sh create mode 100644 src/logging.sh diff --git a/backup.sh b/backup.sh index 95227da..783164e 100755 --- a/backup.sh +++ b/backup.sh @@ -15,8 +15,7 @@ BACKUP_DIRECTORY="" # Directory to save backups in MAX_BACKUPS=128 # -1 indicates unlimited DELETE_METHOD="thin" # Choices: thin, sequential, none; sequential: delete oldest; thin: keep last 24 hourly, last 30 daily, and monthly (use with 1 hr cron interval) COMPRESSION_ALGORITHM="zstd" # Leave empty for no compression -EXIT_IF_NO_SCREEN=false -USE_BORG_BACKUP=false # Use borg backup +EXIT_IF_NO_SCREEN=false # Skip backup if there is no minecraft screen running COMPRESSION_FILE_EXTENSION=".zst" # Leave empty for no compression; Precede with a . (for example: ".gz") COMPRESSION_LEVEL=3 # Passed to the compression algorithm ENABLE_CHAT_MESSAGES=false # Tell players in Minecraft chat about backup status @@ -31,7 +30,6 @@ TIMESTAMP=$(date +$DATE_FORMAT) while getopts 'a:bcd:e:f:ghi:l:m:o:p:qs:v' FLAG; do case $FLAG in a) COMPRESSION_ALGORITHM=$OPTARG ;; - b) USE_BORG_BACKUP=true ;; c) ENABLE_CHAT_MESSAGES=true ;; d) DELETE_METHOD=$OPTARG ;; e) COMPRESSION_FILE_EXTENSION=".$OPTARG" ;; @@ -39,7 +37,6 @@ while getopts 'a:bcd:e:f:ghi:l:m:o:p:qs:v' FLAG; do g) EXIT_IF_NO_SCREEN=true ;; h) echo "Minecraft Backup (by Nicolas Chan)" echo "-a Compression algorithm (default: gzip)" - echo "-b Use borg backup (default: false)" echo "-c Enable chat messages" echo "-d Delete method: thin (default), sequential, none" echo "-e Compression file extension, exclude leading \".\" (default: gz)" @@ -64,17 +61,14 @@ while getopts 'a:bcd:e:f:ghi:l:m:o:p:qs:v' FLAG; do q) SUPPRESS_WARNINGS=true ;; s) SCREEN_NAME=$OPTARG ;; v) DEBUG=true ;; + *) ;; esac done -log-fatal () { - echo -e "\033[0;31mFATAL:\033[0m $*" -} -log-warning () { - if ! $SUPPRESS_WARNINGS; then - echo -e "\033[0;33mWARNING:\033[0m $*" - fi -} +BASE_DIR=$(dirname "$(realpath "$0")") + +# shellcheck source=src/logging.sh +source "$BASE_DIR/src/logging.sh" # Check for missing encouraged arguments if [[ $SCREEN_NAME == "" ]]; then @@ -91,237 +85,26 @@ if [[ $BACKUP_DIRECTORY == "" ]]; then log-fatal "Backup directory not specified (use -o)" MISSING_CONFIGURATION=true fi - if $MISSING_CONFIGURATION; then - exit 0 -fi - -if $USE_BORG_BACKUP; then - ARCHIVE_FILE_NAME=$BACKUP_DIRECTORY - ARCHIVE_PATH="$BACKUP_DIRECTORY"::'{now}' -else - ARCHIVE_FILE_NAME=$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION - ARCHIVE_PATH=$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME -fi - -# Minecraft server screen interface functions -message-players () { - local MESSAGE=$1 - local HOVER_MESSAGE=$2 - message-players-color "$MESSAGE" "$HOVER_MESSAGE" "gray" -} -execute-command () { - local COMMAND=$1 - if ! $(screen -S "$SCREEN_NAME" -Q "select" . &> /dev/null); then - return 1 - fi - if [[ "$SCREEN_NAME" != "" ]]; then - screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)" - fi -} -message-players-error () { - local MESSAGE=$1 - local HOVER_MESSAGE=$2 - message-players-color "$MESSAGE" "$HOVER_MESSAGE" "red" -} -message-players-success () { - local MESSAGE=$1 - local HOVER_MESSAGE=$2 - message-players-color "$MESSAGE" "$HOVER_MESSAGE" "green" -} -message-players-color () { - local MESSAGE=$1 - local HOVER_MESSAGE=$2 - local COLOR=$3 - if $DEBUG; then - echo "$MESSAGE ($HOVER_MESSAGE)" - fi - if $ENABLE_CHAT_MESSAGES; then - execute-command "tellraw @a [\"\",{\"text\":\"[$PREFIX] \",\"color\":\"gray\",\"italic\":true},{\"text\":\"$MESSAGE\",\"color\":\"$COLOR\",\"italic\":true,\"hoverEvent\":{\"action\":\"show_text\",\"value\":{\"text\":\"\",\"extra\":[{\"text\":\"$HOVER_MESSAGE\"}]}}}]" - fi -} - -# Parse file timestamp to one readable by "date" -parse-file-timestamp () { - local DATE_STRING=$(echo $1 | awk -F_ '{gsub(/-/,":",$2); print $1" "$2}') - echo $DATE_STRING -} - -# Delete a backup -delete-backup () { - local BACKUP=$1 - rm -f $BACKUP_DIRECTORY/$BACKUP - message-players "Deleted old backup" "$BACKUP" -} - -# Sequential delete method -delete-sequentially () { - local BACKUPS=($(ls $BACKUP_DIRECTORY)) - while [[ $MAX_BACKUPS -ge 0 && ${#BACKUPS[@]} -gt $MAX_BACKUPS ]]; do - delete-backup ${BACKUPS[0]} - BACKUPS=($(ls $BACKUP_DIRECTORY)) - done -} - -# Functions to sort backups into correct categories based on timestamps -is-hourly-backup () { - local TIMESTAMP=$* - local MINUTE=$(date -d "$TIMESTAMP" +%M) - return $MINUTE -} -is-daily-backup () { - local TIMESTAMP=$* - local HOUR=$(date -d "$TIMESTAMP" +%H) - return $HOUR -} -is-weekly-backup () { - local TIMESTAMP=$* - local DAY=$(date -d "$TIMESTAMP" +%u) - return $((DAY - 1)) -} - -# Helper function to sum an array -array-sum () { - SUM=0 - for NUMBER in $*; do - (( SUM += NUMBER )) - done - echo $SUM -} - -# Thinning delete method -delete-thinning () { - # sub-hourly, hourly, daily, weekly is everything else - local BLOCK_SIZES=(16 24 30) - # First block is unconditional - # The next blocks will only accept files whose names cause these functions to return true (0) - local BLOCK_FUNCTIONS=("is-hourly-backup" "is-daily-backup" "is-weekly-backup") - - # Warn if $MAX_BACKUPS does not have enough room for all the blocks - TOTAL_BLOCK_SIZE=$(array-sum ${BLOCK_SIZES[@]}) - if [[ $TOTAL_BLOCK_SIZE -gt $MAX_BACKUPS ]]; then - log-warning "MAX_BACKUPS ($MAX_BACKUPS) is smaller than TOTAL_BLOCK_SIZE ($TOTAL_BLOCK_SIZE)" - fi - - local CURRENT_INDEX=0 - local BACKUPS=($(ls -r $BACKUP_DIRECTORY)) # List newest first - - for BLOCK_INDEX in ${!BLOCK_SIZES[@]}; do - local BLOCK_SIZE=${BLOCK_SIZES[BLOCK_INDEX]} - local BLOCK_FUNCTION=${BLOCK_FUNCTIONS[BLOCK_INDEX]} - local OLDEST_BACKUP_IN_BLOCK_INDEX=$((BLOCK_SIZE + CURRENT_INDEX)) # Not an off-by-one error because a new backup was already saved - local OLDEST_BACKUP_IN_BLOCK=${BACKUPS[OLDEST_BACKUP_IN_BLOCK_INDEX]} - - if [[ $OLDEST_BACKUP_IN_BLOCK == "" ]]; then - break - fi - - local OLDEST_BACKUP_TIMESTAMP=$(parse-file-timestamp ${OLDEST_BACKUP_IN_BLOCK:0:19}) - local BLOCK_COMMAND="$BLOCK_FUNCTION $OLDEST_BACKUP_TIMESTAMP" - - if $BLOCK_COMMAND; then - # Oldest backup in this block satisfies the condition for placement in the next block - if $DEBUG; then - echo "$OLDEST_BACKUP_IN_BLOCK promoted to next block" - fi - else - # Oldest backup in this block does not satisfy the condition for placement in next block - delete-backup $OLDEST_BACKUP_IN_BLOCK - break - fi - - ((CURRENT_INDEX += BLOCK_SIZE)) - done - - delete-sequentially -} - -# Delete old backups -delete-old-backups () { - case $DELETE_METHOD in - "sequential") delete-sequentially - ;; - "thin") delete-thinning - ;; - esac -} - -clean-up () { - # Re-enable world autosaving - execute-command "save-on" - - # Save the world - execute-command "save-all" -} - -trap-ctrl-c () { - log-warning "Backup interrupted. Attempting to re-enable autosaving" - clean-up - exit 2 -} - -trap "trap-ctrl-c" 2 - -# Check if screen is running -if ! $(screen -S "$SCREEN_NAME" -Q "select" . &> /dev/null); then - if $EXIT_IF_NO_SCREEN; then - log-warning "Screen \"$SCREEN_NAME\" is not running. Exiting without backing up (because of -g option)." - exit 1 - fi -fi - -# Notify players of start -message-players "Starting backup..." "$ARCHIVE_FILE_NAME" - -# Disable world autosaving -execute-command "save-off" - -# Record start time for performance reporting -START_TIME=$(date +"%s") - -if $USE_BORG_BACKUP; then - borg create --compression "$COMPRESSION_ALGORITHM,$COMPRESSION_LEVEL" "$ARCHIVE_PATH" "$SERVER_WORLD" -else - case $COMPRESSION_ALGORITHM in - "") # No compression - tar -cf $ARCHIVE_PATH -C $SERVER_WORLD . - ;; - *) # With compression - tar -cf - -C $SERVER_WORLD . | $COMPRESSION_ALGORITHM -cv -$COMPRESSION_LEVEL - > $ARCHIVE_PATH 2>> /dev/null - ;; -esac -fi - -sync -END_TIME=$(date +"%s") - -clean-up - -# Notify players of completion -if $USE_BORG_BACKUP; then - BORG_LAST_ARCHIVE_INFO=$(borg info "$BACKUP_DIRECTORY" --last 1 --json) - BORG_REPO_INFO=$(borg info "$BACKUP_DIRECTORY" --json) - - WORLD_SIZE_BYTES=$(echo "$BORG_LAST_ARCHIVE_INFO" | jq '.["archives"][0]["stats"]["original_size"]') - ARCHIVE_SIZE_BYTES=$(echo "$BORG_LAST_ARCHIVE_INFO" | jq '.["archives"][0]["stats"]["deduplicated_size"]') - BACKUP_DIRECTORY_SIZE=$(echo "$BORG_REPO_INFO" | jq '.["cache"]["stats"]["unique_csize"]' | numfmt --to=iec) -else - WORLD_SIZE_BYTES=$(du -b --max-depth=0 $SERVER_WORLD | awk '{print $1}') - ARCHIVE_SIZE_BYTES=$(du -b $ARCHIVE_PATH | awk '{print $1}') - BACKUP_DIRECTORY_SIZE=$(du -h --max-depth=0 $BACKUP_DIRECTORY | awk '{print $1}') -fi - -ARCHIVE_SIZE=$(numfmt --to=iec "$ARCHIVE_SIZE_BYTES") -COMPRESSION_PERCENT=$(($ARCHIVE_SIZE_BYTES * 100 / $WORLD_SIZE_BYTES)) -TIME_DELTA=$((END_TIME - START_TIME)) - -# Check that archive size is not null and at least 1024 KB -if [[ "$ARCHIVE_SIZE" != "" && "$ARCHIVE_SIZE_BYTES" -gt 8 ]]; then - message-players-success "Backup complete!" "$TIME_DELTA s, $ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%" - if ! $USE_BORG_BACKUP; then - delete-old-backups - fi -else - message-players-error "Backup was not saved!" "Please notify an administrator" + exit 1 fi +"$BASE_DIR/src/core.sh" \ + "$BASE_DIR/src/exec-methods/screen.sh" \ + -s "$SCREEN_NAME" \ + -- \ + "$BASE_DIR/src/backup-methods/tar.sh" \ + -a "$COMPRESSION_ALGORITHM" \ + -d "$DELETE_METHOD" \ + -e "$COMPRESSION_FILE_EXTENSION" \ + -f "$TIMESTAMP" \ + -i "$SERVER_WORLD" \ + -l "$COMPRESSION_LEVEL" \ + -m "$MAX_BACKUPS" \ + -o "$BACKUP_DIRECTORY" \ + -- \ + -c "$ENABLE_CHAT_MESSAGES" \ + -g "$EXIT_IF_NO_SCREEN" \ + -p "$PREFIX" \ + -q "$SUPPRESS_WARNINGS" \ + -v "$DEBUG" diff --git a/src/backup-methods/tar.sh b/src/backup-methods/tar.sh new file mode 100755 index 0000000..1c0d48c --- /dev/null +++ b/src/backup-methods/tar.sh @@ -0,0 +1,195 @@ +#!/bin/env bash + +# Backup to (compressed) tar archives, and automatically delete + +OPTIND=1 +while getopts 'a:d:e:f:i:l:m:o:' FLAG; do + case $FLAG in + a) COMPRESSION_ALGORITHM=$OPTARG ;; + d) DELETE_METHOD=$OPTARG ;; + e) COMPRESSION_FILE_EXTENSION=$OPTARG ;; + f) TIMESTAMP=$OPTARG ;; + i) SERVER_WORLD=$OPTARG ;; + l) COMPRESSION_LEVEL=$OPTARG ;; + m) MAX_BACKUPS=$OPTARG ;; + o) BACKUP_DIRECTORY=$OPTARG ;; + *) ;; + esac +done + +ARCHIVE_FILE_NAME=$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION +ARCHIVE_PATH=$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME +mkdir -p "$BACKUP_DIRECTORY" + +# Parse file timestamp to one readable by "date" +parse-file-timestamp () { + local DATE_STRING + DATE_STRING=$(echo "$1" | awk -F_ '{gsub(/-/,":",$2); print $1" "$2}') + echo "$DATE_STRING" +} + +# Delete a backup +delete-backup () { + local BACKUP=$1 + rm -f "$BACKUP_DIRECTORY/$BACKUP" + message-players "Deleted old backup" "$BACKUP" +} + +# Sequential delete method +delete-sequentially () { + local BACKUPS_UNSORTED_RAW=("$BACKUP_DIRECTORY"/*) + local BACKUPS_UNSORTED=() + for BACKUP_NAME in "${BACKUPS_UNSORTED_RAW[@]}"; do + local BASENAME + BASENAME=$(basename "$BACKUP_NAME") + BACKUPS_UNSORTED+=("$BASENAME") + done + local BACKUPS=() + # List oldest first + while IFS='' read -r line; do BACKUPS+=("$line"); done < <(IFS=$'\n' sort <<<"${BACKUPS_UNSORTED[*]}") + + while [[ $MAX_BACKUPS -ge 0 && ${#BACKUPS[@]} -gt $MAX_BACKUPS ]]; do + delete-backup "${BACKUPS[0]}" + local BACKUPS_UNSORTED_RAW=("$BACKUP_DIRECTORY"/*) + local BACKUPS_UNSORTED=() + for BACKUP_NAME in "${BACKUPS_UNSORTED_RAW[@]}"; do + local BASENAME + BASENAME=$(basename "$BACKUP_NAME") + BACKUPS_UNSORTED+=("$BASENAME") + done + local BACKUPS=() + # List oldest first + while IFS='' read -r line; do BACKUPS+=("$line"); done < <(IFS=$'\n' sort <<<"${BACKUPS_UNSORTED[*]}") + done +} + +# Functions to sort backups into correct categories based on timestamps +is-hourly-backup () { + local TIMESTAMP=$* + local MINUTE + MINUTE=$(date -d "$TIMESTAMP" +%M) + return "$MINUTE" +} +is-daily-backup () { + local TIMESTAMP=$* + local HOUR + HOUR=$(date -d "$TIMESTAMP" +%H) + return "$HOUR" +} +is-weekly-backup () { + local TIMESTAMP=$* + local DAY + DAY=$(date -d "$TIMESTAMP" +%u) + return $((DAY - 1)) +} + +# Helper function to sum an array +array-sum () { + SUM=0 + for NUMBER in "$@"; do + (( SUM += NUMBER )) + done + echo $SUM +} + +# Thinning delete method +delete-thinning () { + # sub-hourly, hourly, daily, weekly is everything else + local BLOCK_SIZES=(16 24 30) + # First block is unconditional + # The next blocks will only accept files whose names cause these functions to return true (0) + local BLOCK_FUNCTIONS=("is-hourly-backup" "is-daily-backup" "is-weekly-backup") + + # Warn if $MAX_BACKUPS does not have enough room for all the blocks + TOTAL_BLOCK_SIZE=$(array-sum "${BLOCK_SIZES[@]}") + if [[ $TOTAL_BLOCK_SIZE -gt $MAX_BACKUPS ]]; then + log-warning "MAX_BACKUPS ($MAX_BACKUPS) is smaller than TOTAL_BLOCK_SIZE ($TOTAL_BLOCK_SIZE)" + fi + + local CURRENT_INDEX=0 + local BACKUPS_UNSORTED_RAW=("$BACKUP_DIRECTORY"/*) + local BACKUPS_UNSORTED=() + for BACKUP_NAME in "${BACKUPS_UNSORTED_RAW[@]}"; do + local BASENAME + BASENAME=$(basename "$BACKUP_NAME") + BACKUPS_UNSORTED+=("$BASENAME") + done + local BACKUPS=() + # List newest first + while IFS='' read -r line; do BACKUPS+=("$line"); done < <(IFS=$'\n' sort -r <<<"${BACKUPS_UNSORTED[*]}") + + for BLOCK_INDEX in "${!BLOCK_SIZES[@]}"; do + local BLOCK_SIZE=${BLOCK_SIZES[BLOCK_INDEX]} + local BLOCK_FUNCTION=${BLOCK_FUNCTIONS[BLOCK_INDEX]} + local OLDEST_BACKUP_IN_BLOCK_INDEX=$((BLOCK_SIZE + CURRENT_INDEX)) # Not an off-by-one error because a new backup was already saved + local OLDEST_BACKUP_IN_BLOCK=${BACKUPS[OLDEST_BACKUP_IN_BLOCK_INDEX]} + + if [[ $OLDEST_BACKUP_IN_BLOCK == "" ]]; then + break + fi + + local OLDEST_BACKUP_TIMESTAMP + OLDEST_BACKUP_TIMESTAMP=$(parse-file-timestamp "${OLDEST_BACKUP_IN_BLOCK:0:19}") + local BLOCK_COMMAND="$BLOCK_FUNCTION $OLDEST_BACKUP_TIMESTAMP" + + if $BLOCK_COMMAND; then + # Oldest backup in this block satisfies the condition for placement in the next block + if $DEBUG; then + echo "$OLDEST_BACKUP_IN_BLOCK promoted to next block" + fi + else + # Oldest backup in this block does not satisfy the condition for placement in next block + delete-backup "$OLDEST_BACKUP_IN_BLOCK" + break + fi + + ((CURRENT_INDEX += BLOCK_SIZE)) + done + + delete-sequentially +} + + +clean-up () { + # Re-enable world autosaving + execute-command "save-on" + + # Save the world + execute-command "save-all" +} + +minecraft-backup-backup () { + case $COMPRESSION_ALGORITHM in + "") # No compression + tar -cf "$ARCHIVE_PATH" -C "$SERVER_WORLD" . + ;; + *) # With compression + tar -cf - -C "$SERVER_WORLD" . | $COMPRESSION_ALGORITHM -cv -"$COMPRESSION_LEVEL" - > "$ARCHIVE_PATH" 2>> /dev/null + ;; + esac + sync +} + +minecraft-backup-check () { + WORLD_SIZE_BYTES=$(du -b --max-depth=0 "$SERVER_WORLD" | awk '{print $1}') + ARCHIVE_SIZE_BYTES=$(du -b "$ARCHIVE_PATH" | awk '{print $1}') + BACKUP_DIRECTORY_SIZE=$(du -h --max-depth=0 "$BACKUP_DIRECTORY" | awk '{print $1}') + + ARCHIVE_SIZE=$(numfmt --to=iec "$ARCHIVE_SIZE_BYTES") + COMPRESSION_PERCENT=$((ARCHIVE_SIZE_BYTES * 100 / WORLD_SIZE_BYTES)) + # Check that archive size is not null and at least 1024 KB + if [[ "$ARCHIVE_SIZE" != "" && "$ARCHIVE_SIZE_BYTES" -gt 8 ]]; then + echo "$ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%" + else + return 1 + fi +} + +minecraft-backup-epilog () { + case $DELETE_METHOD in + "sequential") delete-sequentially + ;; + "thin") delete-thinning + ;; + esac +} diff --git a/src/core.sh b/src/core.sh new file mode 100755 index 0000000..f219349 --- /dev/null +++ b/src/core.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash + +# Minecraft server automatic backup management script +# by Nicolas Chan +# https://github.com/nicolaschan/minecraft-backup +# MIT License +# +# For most convenience, run automatically with cron. + +# This script implements the core functionality, and expects the following +# functions to be defined by the method scripts: +# +# minecraft-backup-execute "$COMMAND" +# where $COMMAND is a command sent to the Minecraft server console +# minecraft-backup-backup +# which performs a backup +# minecraft-backup-check +# minecraft-backup-epilog + +# Default Configuration +EXECUTE_METHOD="$1" +shift +EXECUTE_METHOD_OPTIONS=() +while [[ $1 != "--" ]]; do + EXECUTE_METHOD_OPTIONS+=("$1") + shift +done +shift +BACKUP_METHOD="$1" +shift +BACKUP_METHOD_OPTIONS=() +while [[ $1 != "--" ]]; do + BACKUP_METHOD_OPTIONS+=("$1") + shift +done +shift + +while getopts 'c:g:p:q:v:' FLAG; do + case $FLAG in + c) ENABLE_CHAT_MESSAGES=$OPTARG ;; + g) EXIT_IF_NO_SCREEN=true ;; + p) PREFIX=$OPTARG ;; + q) SUPPRESS_WARNINGS=$OPTARG ;; + v) DEBUG=$OPTARG ;; + *) ;; + esac +done + +BASE_DIR=$(dirname "$(realpath "$0")") + +# shellcheck source=logging.sh +source "$BASE_DIR/logging.sh" \ + -q "$SUPPRESS_WARNINGS" \ + -v "$DEBUG" + +EXECUTE_METHOD_PATH=$EXECUTE_METHOD +BACKUP_METHOD_PATH=$BACKUP_METHOD + +assert-all () { + local TEST_CMD=$1 + local MESSAGE=$2 + shift 2 + local ITEMS=("$@") + + local RESULT=true + for ITEM in "${ITEMS[@]}"; do + if ! $TEST_CMD "$ITEM"; then + log-fatal "$MESSAGE $ITEM" + RESULT=false + fi + done + if ! $RESULT; then + exit 1 + fi +} + +assert-files-exist () { + assert-all "test -f" "Script not found:" "$@" +} + +assert-files-exist \ + "$EXECUTE_METHOD_PATH" \ + "$BACKUP_METHOD_PATH" + +# shellcheck source=exec-methods/screen.sh +source "$EXECUTE_METHOD_PATH" "${EXECUTE_METHOD_OPTIONS[@]}" + +# shellcheck source=backup-methods/tar.sh +source "$BACKUP_METHOD_PATH" "${BACKUP_METHOD_OPTIONS[@]}" + +# fn_exists based upon https://stackoverflow.com/q/85880 +fn_exists () { + LC_ALL=C type "$1" 2>&1 | grep -q 'function' +} +assert-functions-exist () { + assert-all fn_exists "Function not defined:" "$@" +} + +assert-functions-exist \ + minecraft-backup-execute \ + minecraft-backup-backup \ + minecraft-backup-check \ + minecraft-backup-epilog + +# Minecraft server communication interface functions +execute-command () { + minecraft-backup-execute "$@" +} +message-players () { + message-players-color "gray" "$@" +} +message-players-error () { + message-players-color "red" "$@" +} +message-players-warning () { + message-players-color "yellow" "$@" +} +message-players-success () { + message-players-color "green" "$@" +} +message-players-color () { + local COLOR=$1 + local MESSAGE=$2 + local HOVER_MESSAGE=$3 + log-info "$MESSAGE ($HOVER_MESSAGE)" + if $ENABLE_CHAT_MESSAGES; then + execute_command \ + "tellraw @a [\"\",{\"text\":\"[$PREFIX] \",\"color\":\"gray\",\"italic\":true},{\"text\":\"$MESSAGE\",\"color\":\"$COLOR\",\"italic\":true,\"hoverEvent\":{\"action\":\"show_text\",\"value\":{\"text\":\"\",\"extra\":[{\"text\":\"$HOVER_MESSAGE\"}]}}}]" + fi +} + +clean-up () { + # Re-enable world autosaving + execute-command "save-on" + + # Save the world + execute-command "save-all" +} + +trap-ctrl-c () { + log-warning "Backup interrupted. Attempting to re-enable autosaving" + clean-up + exit 2 +} + +trap "trap-ctrl-c" 2 + +# Notify players of start +message-players "Starting backup..." "$TIMESTAMP" + +# Disable world autosaving +execute-command "save-off" +RESULT=$? +if $EXIT_IF_NO_SCREEN && [[ $RESULT != "0" ]]; then + exit $RESULT +fi + +# Record start time for performance reporting +START_TIME=$(date +"%s") +minecraft-backup-backup +END_TIME=$(date +"%s") + +clean-up + +TIME_DELTA=$((END_TIME - START_TIME)) +CHECK_MESSAGE=$(minecraft-backup-check) +CHECK_RESULT=$? + +if [[ $CHECK_RESULT != "0" ]]; then + message-players-error "Backup failed!" "Please notify an admin." + exit $CHECK_RESULT +fi + +message-players-success "Backup complete!" "$TIME_DELTA s, $CHECK_MESSAGE" + +EPILOG_MESSAGE=$(minecraft-backup-epilog) +EPILOG_RESULT=$? + +if [[ $EPILOG_RESULT != "0" ]]; then + message-players-warning "Backup epilog failed." +fi + +if [[ $EPILOG_MESSAGE != "" ]]; then + message-players "$EPILOG_MESSAGE" +fi diff --git a/src/exec-methods/screen.sh b/src/exec-methods/screen.sh new file mode 100755 index 0000000..e8b46ff --- /dev/null +++ b/src/exec-methods/screen.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# Execute commands on a Minecraft server running in a GNU screen + +OPTIND=1 +while getopts 's:' FLAG "$@"; do + case $FLAG in + s) SCREEN_NAME=$OPTARG ;; + *) ;; + esac +done + +minecraft-backup-execute () { + local COMMAND=$1 + if ! screen -S "$SCREEN_NAME" -Q "select" .; then + return 1 + fi + if [[ "$SCREEN_NAME" != "" ]]; then + screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)" + fi +} diff --git a/src/logging.sh b/src/logging.sh new file mode 100644 index 0000000..ee242e1 --- /dev/null +++ b/src/logging.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +SUPPRESS_WARNINGS=false +DEBUG=true + +while getopts 'q:v:' FLAG; do + case $FLAG in + q) SUPPRESS_WARNINGS=$OPTARG ;; + v) DEBUG=$OPTARG ;; + *) ;; + esac +done + +echo-colored () { + local COLOR_CODE="$1" + shift + local MESSAGE="$*" + if test -t 1 && [ "$(tput colors)" -gt 1 ]; then + # This terminal supports color + echo -ne "\033[${COLOR_CODE}m${MESSAGE}\033[0m" + else + # Output does not support color + echo -n "$MESSAGE" + fi +} +log () { + local COLOR="$1" + local TYPE="$2" + local MESSAGE="$3" + echo-colored "$COLOR" "${TYPE}: " + echo-colored "0" "$MESSAGE" + echo +} +log-fatal () { + >&2 log "0;31" "FATAL" "$*" +} +log-warning () { + if ! $SUPPRESS_WARNINGS; then + >&2 log "0;33" "WARNING" "$*" + fi +} +log-info () { + if $DEBUG; then + log "0;36" "INFO" "$*" + fi +}