diff --git a/README.md b/README.md index 1391112..25fdf80 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Backup script for Linux servers running a Minecraft server in a GNU Screen ### Still in development, so use at your own risk! ## Features -- Create backups of your world folder +- Create backups of your world folders - Manage deletion of old backups - "thin" - keep last 24 hourly, last 30 daily, and use remaining space for monthly backups - "sequential" - delete oldest backup - Choose your own compression algorithm (tested with: `gzip`, `xz`, `zstd`) - Able to print backup status and info to the Minecraft chat +- Can back up as many worlds as you like ## Requirements - Linux computer (tested on Ubuntu) @@ -20,17 +21,21 @@ Backup script for Linux servers running a Minecraft server in a GNU Screen 1. Download the script: `$ wget https://raw.githubusercontent.com/nicolaschan/minecraft-backup/master/backup.sh` 2. Mark as executable: `$ chmod +x backup.sh` 3. Configure the variables (in the top of the script) + or use the commandline options (see ./backup.sh -h for help) ```bash 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 +SERVER_WORLDS=() # Array for input paths (paths of the worlds to back up) +SERVER_WORLDS[0]="/home/server/MinecraftServers/PrivateSurvival/world" # World you want to back up +BACKUP_DIRECTORYS=() # Array for directory to save backups in (invoke in same order as input directorys with -i) +BACKUP_DIRECTORY[0]="/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 +ENABLE_JOINED_BACKUP_MESSAGE=false # Print a combined Backup info messsage after all Backups are finished - if multiple Backups were performed PREFIX="Backup" # Shows in the chat message DEBUG=true # Enable debug messages ``` @@ -45,6 +50,7 @@ DEBUG=true # Enable debug messages - Make sure your compression algorithm is in the crontab's PATH - Make sure cron has permissions for all the files involved and access to the Minecraft server's GNU Screen - It's surprising how much space backups can take--make sure you have enough empty space -- `SERVER_DIRECTORY` should be the server directory, not the `world` directory -- Do not put trailing `/` in the `SERVER_DIRECTORY` or `BACKUP_DIRECTORY` +- Do not put trailing `/` in the `SERVER_WORLDS` or `BACKUP_DIRECTORYs` - If "thin" delete method is behaving weirdly, try emptying your backup directory or switch to "sequential" +- you can back up multiple worlds into seperate folders or all into one +- the backup for first specified world folder goes into the first specified backup folder and so on... diff --git a/backup.sh b/backup.sh index eb69a84..9db381f 100755 --- a/backup.sh +++ b/backup.sh @@ -9,14 +9,15 @@ # 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 +SERVER_WORLDS=() # Array for input paths (paths of the worlds to back up) +BACKUP_DIRECTORYS=() # Array for directory to save backups in (invoke in same order as input directorys with -i) +MAX_BACKUPS=128 # Max. backups per world. -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_LEVEL=3 # Passed to the compression algorithm ENABLE_CHAT_MESSAGES=false # Tell players in Minecraft chat about backup status +ENABLE_JOINED_BACKUP_MESSAGE=false # Print a combined Backup info messsage after all Backups are finished - if multiple Backups were performed PREFIX="Backup" # Shows in the chat message DEBUG=false # Enable debug messages SUPPRESS_WARNINGS=false # Suppress warnings @@ -25,7 +26,7 @@ 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:cd:e:f:hi:jl:m:o:p:qs:v' FLAG; do case $FLAG in a) COMPRESSION_ALGORITHM=$OPTARG ;; c) ENABLE_CHAT_MESSAGES=true ;; @@ -39,7 +40,8 @@ while getopts 'a:cd:e:f:hi:l:m:o:p:qs:v' FLAG; do 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 "-i Input directory (path to world folder) - can be used multiple times" + echo "-j if chat messages is enabled, print an info message after all backups are finished" echo "-l Compression level (default: 3)" echo "-m Maximum backups to keep, use -1 for unlimited (default: 128)" echo "-o Output directory" @@ -49,10 +51,11 @@ while getopts 'a:cd:e:f:hi:l:m:o:p:qs:v' FLAG; do echo "-v Verbose mode" exit 0 ;; - i) SERVER_WORLD=$OPTARG ;; + i) SERVER_WORLDS+=("$OPTARG") ;; + j) ENABLE_JOINED_BACKUP_MESSAGE=true ;; l) COMPRESSION_LEVEL=$OPTARG ;; m) MAX_BACKUPS=$OPTARG ;; - o) BACKUP_DIRECTORY=$OPTARG ;; + o) BACKUP_DIRECTORYS+=("$OPTARG") ;; p) PREFIX=$OPTARG ;; q) SUPPRESS_WARNINGS=true ;; s) SCREEN_NAME=$OPTARG ;; @@ -75,22 +78,22 @@ if ! $SUPPRESS_WARNINGS; then fi # Check for required arguments MISSING_CONFIGURATION=false -if [[ $SERVER_WORLD == "" ]]; then - log-fatal "Server world not specified (use -i)" +if [[ ${#SERVER_WORLDS[@]} -eq 0 ]]; then + log-fatal "No Server world specified (use -i)" MISSING_CONFIGURATION=true fi -if [[ $BACKUP_DIRECTORY == "" ]]; then - log-fatal "Backup directory not specified (use -o)" +if [[ ${#BACKUP_DIRECTORYS[@]} -eq 0 ]]; then + log-fatal "No Backup directory specified (use -o)" + MISSING_CONFIGURATION=true +fi +if [[ ${#BACKUP_DIRECTORYS[@]} -ne 1 && ${#BACKUP_DIRECTORYS[@]} -ne ${#SERVER_WORLDS[@]} ]]; then + log-fatal "To many or less Backup directory(s) specified (must be either 1 directory or for each input input path one)" MISSING_CONFIGURATION=true fi - if $MISSING_CONFIGURATION; then exit 0 fi -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 @@ -125,9 +128,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}') @@ -136,17 +136,20 @@ parse-file-timestamp () { # Delete a backup delete-backup () { - local BACKUP=$1 + local BACKUP=$2 + local BACKUP_DIRECTORY=$1 rm $BACKUP_DIRECTORY/$BACKUP message-players "Deleted old backup" "$BACKUP" } # Sequential delete method delete-sequentially () { - local BACKUPS=($(ls $BACKUP_DIRECTORY)) + local BACKUP_DIRECTORY=$1 + local WORLD_NAME=$2 + local BACKUPS=($(ls $BACKUP_DIRECTORY | grep $WORLD_NAME)) while [[ $MAX_BACKUPS -ge 0 && ${#BACKUPS[@]} -gt $MAX_BACKUPS ]]; do - delete-backup ${BACKUPS[0]} - BACKUPS=($(ls $BACKUP_DIRECTORY)) + delete-backup BACKUP_DIRECTORY ${BACKUPS[0]} + BACKUPS=($(ls $BACKUP_DIRECTORY | grep $WORLD_NAME)) done } @@ -178,6 +181,8 @@ array-sum () { # Thinning delete method delete-thinning () { + local BACKUP_DIRECTORY=$1 + local WORLD_NAME=$2 # sub-hourly, hourly, daily, weekly is everything else local BLOCK_SIZES=(16 24 30) # First block is unconditional @@ -193,7 +198,7 @@ delete-thinning () { fi local CURRENT_INDEX=0 - local BACKUPS=($(ls -r $BACKUP_DIRECTORY)) # List newest first + local BACKUPS=($(ls -r $BACKUP_DIRECTORY | grep $WORLD_NAME)) # List newest first for BLOCK_INDEX in ${!BLOCK_SIZES[@]}; do local BLOCK_SIZE=${BLOCK_SIZES[BLOCK_INDEX]} @@ -215,60 +220,112 @@ delete-thinning () { fi else # Oldest backup in this block does not satisfy the condition for placement in next block - delete-backup $OLDEST_BACKUP_IN_BLOCK + delete-backup $BACKUP_DIRECTORY $OLDEST_BACKUP_IN_BLOCK break fi ((CURRENT_INDEX += BLOCK_SIZE)) done - delete-sequentially + delete-sequentially $BACKUP_DIRECTORY $WORLD_NAME +} + +# Delete old backups +delete-old-backups () { + local BACKUP_DIRECTORY=$1 + case $DELETE_METHOD in + "sequential") delete-sequentially $BACKUP_DIRECTORY $2 + ;; + "thin") delete-thinning $BACKUP_DIRECTORY $2 + ;; + 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_WORLD . - ;; - *) # With compression - tar -cf - -C $SERVER_WORLD . | $COMPRESSION_ALGORITHM -cv -$COMPRESSION_LEVEL - > $ARCHIVE_PATH 2>> /dev/null - ;; -esac -sync -END_TIME=$(date +"%s") +JOINED_START_TIME=$(date +"%s") +JOINED_WORLD_SIZE_BYTES=0 +JOINED_ARCHIVE_SIZE_BYTES=0 +JOINED_ARCHIVE_SIZE=0 +JOINED_BACKUP_DIRECTORY_SIZE=0 +CURRENT_INDEX=0 + +for SERVER_WORLD in "${SERVER_WORLDS[@]}" +do + + WORLD_NAME=$(basename $SERVER_WORLD) + BACKUP_DIRECTORY="" + ARCHIVE_FILE_NAME="" + NOTIFY_ADDITION="" + + if [[ ${#BACKUP_DIRECTORYS[@]} -eq 1 ]]; then + BACKUP_DIRECTORY=${BACKUP_DIRECTORYS[0]} + else + BACKUP_DIRECTORY=${BACKUP_DIRECTORYS[${CURRENT_INDEX}]} + fi + + + if [[ ${#SERVER_WORLDS[@]} -gt 1 ]]; then + ARCHIVE_FILE_NAME=$TIMESTAMP"_"$WORLD_NAME.tar$COMPRESSION_FILE_EXTENSION + NOTIFY_ADDITION=" of ${WORLD_NAME}" + else + ARCHIVE_FILE_NAME=$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION + fi + # Notify players of start + message-players "Starting backup${NOTIFY_ADDITION}..." "$ARCHIVE_FILE_NAME" + + ARCHIVE_PATH=$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME + + # 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") + + + # Notify players of completion + WORLD_SIZE_BYTES=$(du -b --max-depth=0 $SERVER_WORLD | awk '{print $1}') + JOINED_WORLD_SIZE_BYTES=$(($JOINED_WORLD_SIZE_BYTES + $WORLD_SIZE_BYTES)) + ARCHIVE_SIZE_BYTES=$(du -b $ARCHIVE_PATH | awk '{print $1}') + JOINED_ARCHIVE_SIZE_BYTES=$(($JOINED_ARCHIVE_SIZE_BYTES + $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}') + BACKUP_DIRECTORY_SIZE_BYTES=$(du -b --max-depth=0 $BACKUP_DIRECTORY | awk '{print $1}') + JOINED_BACKUP_DIRECTORY_SIZE=$(($JOINED_ARCHIVE_SIZE_BYTES + $BACKUP_DIRECTORY_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${NOTIFY_ADDITION} complete!" "$TIME_DELTA s, $ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%" + delete-old-backups $BACKUP_DIRECTORY $WORLD_NAME + else + message-players-error "Backup${NOTIFY_ADDITION} was not saved!" "Please notify an administrator" + fi + + CURRENT_INDEX=$((INDEX_COUTER+1)) +done + +JOINED_END_TIME=$(date +"%s") +JOINED_COMPRESSION_PERCENT=$(($JOINED_ARCHIVE_SIZE_BYTES * 100 / $JOINED_WORLD_SIZE_BYTES)) +JOINED_TIME_DELTA=$(($JOINED_END_TIME - $JOINED_START_TIME)) +JOINED_ARCHIVE_SIZE=$((JOINED_ARCHIVE_SIZE_BYTES / 1024 / 1024)) +JOINED_BACKUP_DIRECTORY_SIZE=$((JOINED_BACKUP_DIRECTORY_SIZE / 1024 / 1024)) + +if $ENABLE_JOINED_BACKUP_MESSAGE; then + message-players-color "All Backups completed!" "$JOINED_TIME_DELTA s, $JOINED_ARCHIVE_SIZE M/$JOINED_BACKUP_DIRECTORY_SIZE M, $JOINED_COMPRESSION_PERCENT%" "dark_green" +fi # Enable world autosaving execute-command "save-on" - + # Save the world execute-command "save-all" - -# Delete old backups -delete-old-backups () { - case $DELETE_METHOD in - "sequential") delete-sequentially - ;; - "thin") delete-thinning - ;; - esac -} - -# 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}') -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 -else - message-players-error "Backup was not saved!" "Please notify an administrator" -fi