Major refactor to support new backends
This commit is contained in:
parent
5eaae39e73
commit
2ec8f7f4dc
5 changed files with 473 additions and 243 deletions
267
backup.sh
267
backup.sh
|
@ -15,8 +15,7 @@ BACKUP_DIRECTORY="" # Directory to save backups in
|
||||||
MAX_BACKUPS=128 # -1 indicates unlimited
|
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)
|
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_ALGORITHM="zstd" # Leave empty for no compression
|
||||||
EXIT_IF_NO_SCREEN=false
|
EXIT_IF_NO_SCREEN=false # Skip backup if there is no minecraft screen running
|
||||||
USE_BORG_BACKUP=false # Use borg backup
|
|
||||||
COMPRESSION_FILE_EXTENSION=".zst" # Leave empty for no compression; Precede with a . (for example: ".gz")
|
COMPRESSION_FILE_EXTENSION=".zst" # Leave empty for no compression; Precede with a . (for example: ".gz")
|
||||||
COMPRESSION_LEVEL=3 # Passed to the compression algorithm
|
COMPRESSION_LEVEL=3 # Passed to the compression algorithm
|
||||||
ENABLE_CHAT_MESSAGES=false # Tell players in Minecraft chat about backup status
|
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
|
while getopts 'a:bcd:e:f:ghi:l:m:o:p:qs:v' FLAG; do
|
||||||
case $FLAG in
|
case $FLAG in
|
||||||
a) COMPRESSION_ALGORITHM=$OPTARG ;;
|
a) COMPRESSION_ALGORITHM=$OPTARG ;;
|
||||||
b) USE_BORG_BACKUP=true ;;
|
|
||||||
c) ENABLE_CHAT_MESSAGES=true ;;
|
c) ENABLE_CHAT_MESSAGES=true ;;
|
||||||
d) DELETE_METHOD=$OPTARG ;;
|
d) DELETE_METHOD=$OPTARG ;;
|
||||||
e) COMPRESSION_FILE_EXTENSION=".$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 ;;
|
g) EXIT_IF_NO_SCREEN=true ;;
|
||||||
h) echo "Minecraft Backup (by Nicolas Chan)"
|
h) echo "Minecraft Backup (by Nicolas Chan)"
|
||||||
echo "-a Compression algorithm (default: gzip)"
|
echo "-a Compression algorithm (default: gzip)"
|
||||||
echo "-b Use borg backup (default: false)"
|
|
||||||
echo "-c Enable chat messages"
|
echo "-c Enable chat messages"
|
||||||
echo "-d Delete method: thin (default), sequential, none"
|
echo "-d Delete method: thin (default), sequential, none"
|
||||||
echo "-e Compression file extension, exclude leading \".\" (default: gz)"
|
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 ;;
|
q) SUPPRESS_WARNINGS=true ;;
|
||||||
s) SCREEN_NAME=$OPTARG ;;
|
s) SCREEN_NAME=$OPTARG ;;
|
||||||
v) DEBUG=true ;;
|
v) DEBUG=true ;;
|
||||||
|
*) ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
log-fatal () {
|
BASE_DIR=$(dirname "$(realpath "$0")")
|
||||||
echo -e "\033[0;31mFATAL:\033[0m $*"
|
|
||||||
}
|
# shellcheck source=src/logging.sh
|
||||||
log-warning () {
|
source "$BASE_DIR/src/logging.sh"
|
||||||
if ! $SUPPRESS_WARNINGS; then
|
|
||||||
echo -e "\033[0;33mWARNING:\033[0m $*"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check for missing encouraged arguments
|
# Check for missing encouraged arguments
|
||||||
if [[ $SCREEN_NAME == "" ]]; then
|
if [[ $SCREEN_NAME == "" ]]; then
|
||||||
|
@ -91,237 +85,26 @@ if [[ $BACKUP_DIRECTORY == "" ]]; then
|
||||||
log-fatal "Backup directory not specified (use -o)"
|
log-fatal "Backup directory not specified (use -o)"
|
||||||
MISSING_CONFIGURATION=true
|
MISSING_CONFIGURATION=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $MISSING_CONFIGURATION; then
|
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
|
exit 1
|
||||||
fi
|
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"
|
|
||||||
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"
|
||||||
|
|
195
src/backup-methods/tar.sh
Executable file
195
src/backup-methods/tar.sh
Executable file
|
@ -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
|
||||||
|
}
|
185
src/core.sh
Executable file
185
src/core.sh
Executable file
|
@ -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
|
21
src/exec-methods/screen.sh
Executable file
21
src/exec-methods/screen.sh
Executable file
|
@ -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
|
||||||
|
}
|
46
src/logging.sh
Normal file
46
src/logging.sh
Normal file
|
@ -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
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue