diff --git a/backup.sh b/backup.sh new file mode 100644 index 0000000..797392f --- /dev/null +++ b/backup.sh @@ -0,0 +1,134 @@ +#!/bin/bash + +# Minecraft server automatic backup management script +# by Nicolas Chan +# MIT License +# +# For Minecraft servers running in a GNU screen. +# For most convenience, run automatically with cron. + +# Configuration +SCREEN_NAME="PrivateSurvival" # Name of the GNU Screen your Minecraft server is running in +SERVER_DIRECTORY="/home/server/MinecraftServers/PrivateSurvival" # Server directory, NOT the world; world is SERVER_DIRECTORY/world +BACKUP_DIRECTORY="/media/server/ExternalStorage/Backups/PrivateSurvivalBackups" # Directory to save backups in +NUMBER_OF_BACKUPS_TO_KEEP=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 +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=true # Tell players in Minecraft chat about backup status +PREFIX="Backup" # Shows in the chat message +DEBUG=true # Enable debug messages + +# Other Variables (do not modify) +DATE_FORMAT="%F_%H-%M-%S" +TIMESTAMP=$(date +$DATE_FORMAT) +ARCHIVE_FILE_NAME=$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION +ARCHIVE_PATH=$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME + +# Minecraft server screen interface functions +message-players () { + local MESSAGE=$1 + local HOVER_MESSAGE=$2 + echo "$MESSAGE ($HOVER_MESSAGE)" + if $ENABLE_CHAT_MESSAGES; then + screen -S $SCREEN_NAME -p 0 -X stuff "tellraw @a [\"\",{\"text\":\"[$PREFIX] \",\"color\":\"gray\",\"italic\":true},{\"text\":\"$MESSAGE\",\"color\":\"gray\",\"italic\":true,\"hoverEvent\":{\"action\":\"show_text\",\"value\":{\"text\":\"\",\"extra\":[{\"text\":\"$HOVER_MESSAGE\"}]}}}]$(printf \\r)" + fi +} +execute-command () { + local COMMAND=$1 + screen -S $SCREEN_NAME -p 0 -X stuff "$COMMAND$(printf \\r)" +} + +# Notify players of start +message-players "Starting backup..." "$ARCHIVE_FILE_NAME" + +# Sequential delete method +delete-sequentially () { + local BACKUPS=($(ls $BACKUP_DIRECTORY)) + while [[ $NUMBER_OF_BACKUPS_TO_KEEP -ge 0 && ${#BACKUPS[@]} -ge $NUMBER_OF_BACKUPS_TO_KEEP ]]; do + rm $BACKUP_DIRECTORY/${BACKUPS[0]} + message-players "Deleted old backup" "${BACKUPS[0]}" + BACKUPS=($(ls $BACKUP_DIRECTORY)) + done +} + +# Thinning delete method +delete-thinning () { + local HOURLY_BLOCK_SIZE=24 + local DAILY_BLOCK_SIZE=30 + local BACKUPS=($(ls -r $BACKUP_DIRECTORY)) # List newest first + + local OLDEST_HOURLY_BACKUP=${BACKUPS[HOURLY_BLOCK_SIZE - 1]} + local OLDEST_HOURLY_BACKUP_HOUR=${OLDEST_HOURLY_BACKUP:11:2} + + # DEBUG: Log the oldest hourly backup to console + if $DEBUG; then + echo "Oldest hourly backup is: $OLDEST_HOURLY_BACKUP" + fi + + # If the oldest hourly backup was not at midnight, delete it + if [[ "$OLDEST_HOURLY_BACKUP_HOUR" -ne "00" ]] && [[ "$OLDEST_HOURLY_BACKUP" != "" ]]; then + rm $BACKUP_DIRECTORY/$OLDEST_HOURLY_BACKUP + message-players "Deleted old backup" "$OLDEST_HOURLY_BACKUP" + else + # Oldest hourly backup was at midnight, so it is now a daily backup + local OLDEST_DAILY_BACKUP=${BACKUPS[HOURLY_BLOCK_SIZE + DAILY_BLOCK_SIZE - 1]} + local OLDEST_DAILY_BACKUP_DAY=${OLDEST_DAILY_BACKUP:8:2} + + # DEBUG: Log the oldest hourly backup to console + if $DEBUG; then + echo "Oldest daily backup is: $OLDEST_DAILY_BACKUP" + fi + + # If the oldest daily backup was not on the 1st, delete it + if [[ "$OLDEST_DAILY_BACKUP_DAY" -ne "01" ]] && [[ "$OLDEST_DAILY_BACKUP" != "" ]]; then + rm $BACKUP_DIRECTORY/$OLDEST_DAILY_BACKUP + message-players "Deleted old backup" "$OLDEST_DAILY_BACKUP" + else + # Oldest daily backup was on the 1st, so it is now a monthly backup + delete-sequentially # Delete old monthly backups + fi + fi +} + +# Delete old backups +case $DELETE_METHOD in + "sequential") delete-sequentially + ;; + "thin") delete-thinning + ;; +esac + +# 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_DIRECTORY world + ;; + *) # With compression + tar -cf - -C $SERVER_DIRECTORY world | $COMPRESSION_ALGORITHM -cv -$COMPRESSION_LEVEL - > $ARCHIVE_PATH + ;; +esac +sync $ARCHIVE_PATH +END_TIME=$(date +"%s") + +# Enable world autosaving +execute-command "save-on" + +# Save the world +execute-command "save-all" + +# Notify players of completion +WORLD_SIZE_KB=$(du --max-depth=0 $SERVER_DIRECTORY/world | awk '{print $1}') +ARCHIVE_SIZE_KB=$(du $ARCHIVE_PATH | awk '{print $1}') +COMPRESSION_PERCENT=$(($ARCHIVE_SIZE_KB * 100 / $WORLD_SIZE_KB)) +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)) + +message-players "Backup complete!" "$TIME_DELTA s, $ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%" +