diff --git a/backup-borg.sh b/backup-borg.sh deleted file mode 100755 index 4c622ca..0000000 --- a/backup-borg.sh +++ /dev/null @@ -1,302 +0,0 @@ -#!/bin/bash - -# Minecraft server automatic backup management script -# by Nicolas Chan -# https://github.com/nicolaschan/minecraft-backup -# MIT License -# -# For Minecraft servers running in a GNU screen. -# For most convenience, run automatically with cron. - -# Default Configuration -SCREEN_NAME="" # Name of the GNU Screen your Minecraft server is running in -SERVER_WORLD="" # Server world directory -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 -USE_BORG_BACKUP=false # Use borg backup -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 -PREFIX="Backup" # Shows in the chat message -DEBUG=false # Enable debug messages -SUPPRESS_WARNINGS=false # Suppress warnings - -# Other Variables (do not modify) -DATE_FORMAT="%F_%H-%M-%S" -TIMESTAMP=$(date +$DATE_FORMAT) - -while getopts 'a:bcd:e:f:hi: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" ;; - f) TIMESTAMP=$OPTARG ;; - 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)" - echo "-f Output file name (default is the timestamp)" - echo "-h Shows this help text" - echo "-i Input directory (path to world folder)" - echo "-l Compression level (default: 3)" - echo "-m Maximum backups to keep, use -1 for unlimited (default: 128)" - echo "-o Output directory" - echo "-p Prefix that shows in Minecraft chat (default: Backup)" - echo "-q Suppress warnings" - echo "-s Minecraft server screen name" - echo "-v Verbose mode" - exit 0 - ;; - i) SERVER_WORLD=$OPTARG ;; - l) COMPRESSION_LEVEL=$OPTARG ;; - m) MAX_BACKUPS=$OPTARG ;; - o) BACKUP_DIRECTORY=$OPTARG ;; - p) PREFIX=$OPTARG ;; - q) SUPPRESS_WARNINGS=true ;; - s) SCREEN_NAME=$OPTARG ;; - v) DEBUG=true ;; - esac -done - -log-fatal () { - echo -e "\033[0;31mFATAL:\033[0m $*" -} -log-warning () { - echo -e "\033[0;33mWARNING:\033[0m $*" -} - -# Check for missing encouraged arguments -if ! $SUPPRESS_WARNINGS; then - if [[ $SCREEN_NAME == "" ]]; then - log-warning "Minecraft screen name not specified (use -s)" - fi -fi -# Check for required arguments -MISSING_CONFIGURATION=false -if [[ $SERVER_WORLD == "" ]]; then - log-fatal "Server world not specified (use -i)" - MISSING_CONFIGURATION=true -fi -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_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 - if ! $SUPPRESS_WARNINGS; then - log-warning "MAX_BACKUPS ($MAX_BACKUPS) is smaller than TOTAL_BLOCK_SIZE ($TOTAL_BLOCK_SIZE)" - fi - 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 -} - -# 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") - -# Enable world autosaving -execute-command "save-on" - -# Save the world -execute-command "save-all" - -# 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" -fi - diff --git a/backup.sh b/backup.sh index eb69a84..95227da 100755 --- a/backup.sh +++ b/backup.sh @@ -1,7 +1,8 @@ -#!/bin/bash +#!/bin/env bash # Minecraft server automatic backup management script # by Nicolas Chan +# https://github.com/nicolaschan/minecraft-backup # MIT License # # For Minecraft servers running in a GNU screen. @@ -13,8 +14,10 @@ SERVER_WORLD="" # Server world directory 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="gzip" # Leave empty for no compression -COMPRESSION_FILE_EXTENSION=".gz" # Leave empty for no compression; Precede with a . (for example: ".gz") +COMPRESSION_ALGORITHM="zstd" # Leave empty for no compression +EXIT_IF_NO_SCREEN=false +USE_BORG_BACKUP=false # Use borg backup +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 PREFIX="Backup" # Shows in the chat message @@ -25,19 +28,23 @@ SUPPRESS_WARNINGS=false # Suppress warnings DATE_FORMAT="%F_%H-%M-%S" TIMESTAMP=$(date +$DATE_FORMAT) -while getopts 'a:cd:e:f:hi:l:m:o:p:qs:v' FLAG; do +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" ;; f) TIMESTAMP=$OPTARG ;; + 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)" echo "-f Output file name (default is the timestamp)" + echo "-g Do not backup (exit) if screen is not running (default: always backup)" echo "-h Shows this help text" echo "-i Input directory (path to world folder)" echo "-l Compression level (default: 3)" @@ -64,15 +71,16 @@ log-fatal () { echo -e "\033[0;31mFATAL:\033[0m $*" } log-warning () { - echo -e "\033[0;33mWARNING:\033[0m $*" + if ! $SUPPRESS_WARNINGS; then + echo -e "\033[0;33mWARNING:\033[0m $*" + fi } # Check for missing encouraged arguments -if ! $SUPPRESS_WARNINGS; then - if [[ $SCREEN_NAME == "" ]]; then - log-warning "Minecraft screen name not specified (use -s)" - fi +if [[ $SCREEN_NAME == "" ]]; then + log-warning "Minecraft screen name not specified (use -s)" fi + # Check for required arguments MISSING_CONFIGURATION=false if [[ $SERVER_WORLD == "" ]]; then @@ -88,8 +96,13 @@ if $MISSING_CONFIGURATION; then exit 0 fi -ARCHIVE_FILE_NAME=$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION -ARCHIVE_PATH=$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME +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 () { @@ -99,8 +112,11 @@ message-players () { } execute-command () { local COMMAND=$1 - if [[ $SCREEN_NAME != "" ]]; then - screen -S $SCREEN_NAME -p 0 -X stuff "$COMMAND$(printf \\r)" + 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 () { @@ -125,9 +141,6 @@ message-players-color () { fi } -# Notify players of start -message-players "Starting backup..." "$ARCHIVE_FILE_NAME" - # Parse file timestamp to one readable by "date" parse-file-timestamp () { local DATE_STRING=$(echo $1 | awk -F_ '{gsub(/-/,":",$2); print $1" "$2}') @@ -137,7 +150,7 @@ parse-file-timestamp () { # Delete a backup delete-backup () { local BACKUP=$1 - rm $BACKUP_DIRECTORY/$BACKUP + rm -f $BACKUP_DIRECTORY/$BACKUP message-players "Deleted old backup" "$BACKUP" } @@ -187,9 +200,7 @@ delete-thinning () { # 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 - if ! $SUPPRESS_WARNINGS; then - log-warning "MAX_BACKUPS ($MAX_BACKUPS) is smaller than TOTAL_BLOCK_SIZE ($TOTAL_BLOCK_SIZE)" - fi + log-warning "MAX_BACKUPS ($MAX_BACKUPS) is smaller than TOTAL_BLOCK_SIZE ($TOTAL_BLOCK_SIZE)" fi local CURRENT_INDEX=0 @@ -225,28 +236,6 @@ delete-thinning () { delete-sequentially } -# Disable world autosaving -execute-command "save-off" - -# Backup world -START_TIME=$(date +"%s") -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 -END_TIME=$(date +"%s") - -# Enable world autosaving -execute-command "save-on" - -# Save the world -execute-command "save-all" - # Delete old backups delete-old-backups () { case $DELETE_METHOD in @@ -257,18 +246,82 @@ delete-old-backups () { 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 -WORLD_SIZE_BYTES=$(du -b --max-depth=0 $SERVER_WORLD | awk '{print $1}') -ARCHIVE_SIZE_BYTES=$(du -b $ARCHIVE_PATH | awk '{print $1}') +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)) -ARCHIVE_SIZE=$(du -h $ARCHIVE_PATH | awk '{print $1}') -BACKUP_DIRECTORY_SIZE=$(du -h --max-depth=0 $BACKUP_DIRECTORY | awk '{print $1}') 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%" - delete-old-backups + if ! $USE_BORG_BACKUP; then + delete-old-backups + fi else message-players-error "Backup was not saved!" "Please notify an administrator" fi +