Compare commits

..

20 commits
master ... dev

Author SHA1 Message Date
Nicolas Chan
07510de420 Mention linger 2020-03-22 20:16:02 -07:00
Nicolas Chan
a569083736 Add more info to chat 2020-03-22 01:53:51 -07:00
Nicolas Chan
3afbdc712f Remove restic stats because it's slow 2020-03-21 22:28:36 -07:00
Nicolas Chan
bbab6535a1 Fix EXIT_IF_NO_SCREEN 2020-03-21 21:13:24 -07:00
Nicolas Chan
9f24441733
Update README.md 2020-03-21 21:09:27 -07:00
Nicolas Chan
7f62b04d34
Update README.md 2020-03-21 21:07:37 -07:00
Nicolas Chan
2d439bf042
Update README.md 2020-03-21 21:05:56 -07:00
Nicolas Chan
5bf452994f
Update README.md 2020-03-21 21:04:58 -07:00
Nicolas Chan
66a77abbca
Update README.md 2020-03-21 21:03:12 -07:00
Nicolas Chan
27ab7e2087
Update README.md 2020-03-21 21:01:15 -07:00
Nicolas Chan
9605352be0
Update README.md 2020-03-21 21:00:42 -07:00
Nicolas Chan
4350eeb9ae Update readme with examples 2020-03-21 20:54:21 -07:00
Nicolas Chan
7e4772006f Add restic backend and update readme 2020-03-21 20:14:33 -07:00
Nicolas Chan
94e894645f Bug fixes for deleting methods 2020-03-21 19:09:49 -07:00
Nicolas Chan
8cf268f40b Fix default to be gzip 2020-03-21 17:34:24 -07:00
Nicolas Chan
2ec8f7f4dc Major refactor to support new backends 2020-03-21 17:33:21 -07:00
Nicolas Chan
5eaae39e73
Add -g option 2019-09-12 13:46:03 -07:00
Nicolas Chan
02985989af
Remove tmpdir copy 2019-08-11 17:56:54 -07:00
Nicolas Chan
7d548ce28e
Add borg executable bit 2019-08-11 17:55:05 -07:00
Nicolas Chan
f005e54965
Add borg backup option 2019-08-11 13:51:30 -07:00
23 changed files with 789 additions and 1520 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -1,102 +0,0 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the action will run.
on:
push:
pull_request:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Install kcov
run: sudo apt-get install -y kcov
- name: Install restic
run: |
wget https://github.com/restic/restic/releases/download/v0.12.0/restic_0.12.0_linux_amd64.bz2
bzip2 -d restic_0.12.0_linux_amd64.bz2
sudo cp restic_0.12.0_linux_amd64 /usr/local/bin/restic
sudo chmod +x /usr/local/bin/restic
- name: Run tests
run: test/test.sh
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Install kcov
run: sudo apt-get install -y kcov
- name: Install restic
run: |
wget https://github.com/restic/restic/releases/download/v0.12.0/restic_0.12.0_linux_amd64.bz2
bzip2 -d restic_0.12.0_linux_amd64.bz2
sudo cp restic_0.12.0_linux_amd64 /usr/local/bin/restic
sudo chmod +x /usr/local/bin/restic
- name: Run coverage
run: kcov --include-pattern=backup.sh "$(pwd)"/coverage test/test.sh
- name: Codecov
uses: codecov/codecov-action@v1.2.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage/test.sh/cov.xml
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Install shellcheck
run: sudo apt-get install -y shellcheck
- name: shellcheck backup.sh
run: shellcheck backup.sh
- name: shellcheck test.sh
run: shellcheck test/test.sh
release:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, coverage, shellcheck]
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Create release
uses: ncipollo/release-action@v1.8.6
with:
artifacts: "backup.sh"
token: ${{ secrets.GITHUB_TOKEN }}
docker:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, coverage, shellcheck]
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
version: latest
- name: ghcr.io login
run: |
echo ${{ secrets.CR_PAT }} | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin
- name: Run Buildx
run: |
docker buildx build \
--pull \
--push \
--platform linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 \
--tag ghcr.io/nicolaschan/minecraft-backup:${GITHUB_REF#refs/*/} \
--tag ghcr.io/nicolaschan/minecraft-backup:latest .

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
coverage
.direnv

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "test/shunit2"]
path = test/shunit2
url = https://github.com/kward/shunit2.git

View file

@ -1,10 +0,0 @@
FROM alpine
LABEL org.opencontainers.image.source=https://forgejo.nevy.xyz/nev/minecraft-backup
RUN apk add bash coreutils xxd restic util-linux openssh rclone
WORKDIR /code
COPY ./backup.sh .
ENTRYPOINT ["/bin/sh", "-c"]

221
README.md
View file

@ -1,56 +1,48 @@
# Minecraft Backup # Minecraft Backup
[![CI](https://github.com/nicolaschan/minecraft-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolaschan/minecraft-backup/actions/workflows/ci.yml) Backup script for Linux servers running a Minecraft server in a GNU Screen, supporting saving in compressed tar format or to [restic](https://github.com/restic/restic).
[![codecov](https://codecov.io/gh/nicolaschan/minecraft-backup/branch/master/graph/badge.svg?token=LCbVC4TbYJ)](https://codecov.io/gh/nicolaschan/minecraft-backup)
Backup script for Minecraft servers on Linux. ## Quick Start
Supports servers running in [screen](https://en.wikipedia.org/wiki/GNU_Screen), [tmux](https://en.wikipedia.org/wiki/Tmux), or with [RCON](https://wiki.vg/RCON) enabled. Supports `tar` file or [`restic`](https://restic.net/) backup backends. ```bash
# Download the scripts
git clone https://github.com/nicolaschan/minecraft-backup.git
./minecraft-backup/backup.sh -c -s $SCREEN_NAME -i $WORLD_DIR -o $BACKUP_DIR
```
## Why?
### Why not just put `tar` in crontab?
If the Minecraft server is currently running, you need to disable world autosaving, or you will likely get an error like this:
```
tar: /some/path/here/world/region/r.1.11.mca: file changed as we read it
```
This script will take care of disabling and then re-enabling autosaving for you, and also alert players in the chat of successful backups or errors.
This way, you don't have to shut down the server to take backups.
You'll also probably need some way to delete old backups, and this script can handle keeping either a number of most recent backups, or thinning them out based on hour/day/week. It can also use another backend such as [restic](https://github.com/restic/restic).
### Alternatives
This script is developed with vanilla servers in mind. If you are running a server with plugins or mods, then you can probably find a backup plugin/mod to do a similar job.
## Features ## Features
- Create backups of your world folder - Create backups of your world folder
- Manage deletion of old backups - Manage deletion of old backups
- "thin" - keep last 24 hourly, last 30 daily, and use remaining space for weekly backups - "thin" - keep last 24 hourly, last 30 daily, and use remaining space for monthly backups
- "sequential" - delete oldest backup - "sequential" - delete oldest backup
- Works on vanilla (no plugins required) - Choose your own compression algorithm (tested with: `gzip`, `xz`, `zstd`)
- Print backup status to the Minecraft chat - Print backup status and info to the Minecraft chat
- Customizable backup backends and Minecraft server interface (currently supports locally managed tar archives or [restic](https://github.com/restic/restic))
## Install ## Requirements
- Linux computer (tested on Arch Linux)
- GNU Screen (running your Minecraft server)
- Minecraft server
## Installation
```bash ```bash
wget https://github.com/nicolaschan/minecraft-backup/releases/latest/download/backup.sh # Download the scripts
chmod +x backup.sh git clone https://github.com/nicolaschan/minecraft-backup.git
``` ```
**NOTE**: You will need to keep `backup.sh` in the same directory as the `src/` directory, since it looks for dependencies in `src/`.
Make sure your system has `tar` and your chosen compression algorithm (`gzip` by default) installed. ## Usage Options
If using RCON, you will also need to have the [`xxd`](https://linux.die.net/man/1/xxd) command.
## Usage
```bash
# If connecting with RCON:
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s localhost:25575:secret -w rcon
# If running on screen called "minecraft":
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s minecraft
# If running on tmux session 0:
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s 0 -w tmux
# Using restic (and RCON)
export RESTIC_PASSWORD="restic-pass-secret" # your password here
./backup.sh -c -i /home/user/server/world -r /mnt/storage/backups-restic -s localhost:25575:secret -w rcon
# Using Docker and RCON
# You will have to set up networking so that this Docker image can access the RCON server
# In this example, the RCON server hostname is `server-host`
docker run \
-v /home/user/server/world:/mnt/server \
-v /mnt/storage/backups:/mnt/backups \
ghcr.io/nicolaschan/minecraft-backup -c -i /mnt/server -o /mnt/backups -s server-host:25575:secret -w rcon
# Using itzg/docker-minecraft-server container and rcon cli
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s container-name -w docker-rcon
```
This will show chat messages (`-c`) and save a backup of `/home/user/server/world` into `/mnt/storage/backups` using the default thinning deletion policy for old backups.
Command line options: Command line options:
```text ```text
-a Compression algorithm (default: gzip) -a Compression algorithm (default: gzip)
@ -58,67 +50,158 @@ Command line options:
-d Delete method: thin (default), sequential, none -d Delete method: thin (default), sequential, none
-e Compression file extension, exclude leading "." (default: gz) -e Compression file extension, exclude leading "." (default: gz)
-f Output file name (default is the timestamp) -f Output file name (default is the timestamp)
-g Do not backup (exit) if screen is not running (default: always backup)
-h Shows this help text -h Shows this help text
-H Set hostname for restic backup (restic only) -i Input directory (path to world folder)
-i Input directory (path to world folder, use -i once for each world)
-l Compression level (default: 3) -l Compression level (default: 3)
-m Maximum backups to keep, use -1 for unlimited (default: 128) -m Maximum backups to keep, use -1 for unlimited (default: 128)
-o Output directory -o Output directory
-p Prefix that shows in Minecraft chat (default: Backup) -p Prefix that shows in Minecraft chat (default: Backup)
-q Suppress warnings -q Suppress warnings
-r Restic repo name (if using restic) -s Minecraft server screen name
-s Screen name, tmux session name, hostname:port:password for RCON or [container name](https://github.com/itzg/docker-minecraft-server) for docker-rcon
-t Enable lock file (lock file not used by default)
-u Lock file timeout seconds (empty = unlimited)
-v Verbose mode -v Verbose mode
-w Window manager: screen (default), tmux, RCON
``` ```
### Automate backups with cron ## Example Usage
- Edit the crontab with `crontab -e` ### One-off Example
```bash
./backup.sh -c -s minecraft -i minecraft-server/world -o backups/
```
In this example, we print the status to the Minecraft chat (`-c`), use `minecraft` as the name of the screen, and save a backup of `minecraft-server/world` into `backups/` using the default thinning delete policy for old backups. While this works for performing a single backup, it is _highly_ recommended that you automate your backups.
### Automated with cron
- Edit the crontab:
```bash
crontab -e
```
- Example for hourly backups: - Example for hourly backups:
``` ```
00 * * * * /path/to/backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s minecraft 00 * * * * /path/to/minecraft-backup/backup.sh -c -s minecraft -i /path/to/minecraft-server/world -o /path/to/backups
``` ```
### Automated using systemd timers
#### Simple example (single server)
`~/.config/systemd/user/minecraft-backup.timer`
```systemd
[Unit]
Description=Run Minecraft backup hourly
[Timer]
OnCalendar=hourly
Persistent=false
Unit=minecraft-backup.service
[Install]
WantedBy=timers.target
```
`~/.config/systemd/user/minecraft-backup.service`
```systemd
[Unit]
Description=Perform Minecraft backup
[Service]
Type=oneshot
ExecStart=/path/to/minecraft-backup/backup.sh -c -s minecraft -i /path/to/world -o /path/to/backups
[Install]
WantedBy=multi-user.target
```
Then you can run the following to enable the timer:
```bash
# enable the timer right now only
systemctl --user start minecraft-backup.timer
# start the timer on reboot
systemctl --user enable minecraft-backup.timer
# see status of timers
systemctl --user list-timers
```
#### Advanced example (with restic and multiple servers)
If you have multiple servers, you can use `@` to create timers on-demand for each server. This assumes the server directories are named the same as the screen name.
To start at boot, you may also need to enable linger:
```bash
loginctl enable-linger "$USER"
```
`~/.config/systemd/user/minecraft-backup.timer`
```systemd
[Unit]
Description=Run Minecraft backup hourly
[Timer]
OnCalendar=hourly
Persistent=false
Unit=minecraft-backup@.service
[Install]
WantedBy=timers.target
```
`~/.config/systemd/user/minecraft-backup@.service`
```systemd
[Unit]
Description=Perform Minecraft backup
[Service]
Type=oneshot
Environment="RESTIC_PASSWORD_FILE=/path/to/restic-password.txt"
ExecStart=/path/to/minecraft-backup/backup-restic.sh -c -s %i -i /path/to/server/%i/world -o /path/to/restic-repo
[Install]
WantedBy=multi-user.target
```
To enable:
```bash
systemctl --user enable minecraft-backup@your_server_name_here
```
## Retrieving Backups ## Retrieving Backups
Always test your backups! Backups are in the `tar` format and compressed depending on the option you choose. To restore, first decompress if necessary and then extract using tar. You may be able to do this in one command if `tar` supports your compression option, as is the case with `gzip`: Always test your backups! Backups are in the `tar` format and compressed depending on the option you choose. To restore, first decompress if necessary and then extract using `tar`. You may be able to do this in one command if `tar` supports your compression option, as is the case with `gzip`:
Example: Example:
```bash ```bash
mkdir restored-world mkdir restored-world
cd restored-world # if using gzip:
tar -xzvf /path/to/backups/2019-04-09_02-15-01.tar.gz gzip -cd 2017-07-31_00-00-00.tar.gz | tar xf - -C restored-world
# if using zstd:
zstd -cd 2017-07-31_00-00-00.tar.zst | tar xf - -C restored-world
``` ```
The restored worlds should be inside the `restored-world` directory, possibly nested under the parent directories. Then you can move your restored world to your Minecraft server folder under the proper name and path so the Minecraft server uses it. Then you can move your restored world (`restored-world` in this case) to your Minecraft server folder and rename it (usually called `world`) so the Minecraft server uses it.
### With `restic` ## Using [restic](https://github.com/restic/restic)
Use [`restic restore`](https://restic.readthedocs.io/en/latest/050_restore.html) to restore from backup. The `backup-restic.sh` script provides a similar interface for restic.
To specify your repository's password, you'll need to export the `$RESTIC_PASSWORD_FILE` or `$RESTIC_PASSWORD_COMMAND` environment variable.
## Why not use `tar` directly? ```bash
If you use `tar` while the server is running, you will likely get an error like this because Minecraft autosaves the world periodically: restic init -r /path/to/restic-repo
touch restic-password.txt # make a new file for your restic password
chmod 600 restic-password.txt # make sure only you can read your password
echo "my_restic-password" > restic-password.txt
export RESTIC_PASSWORD_FILE=$(pwd)/restic_password.txt
/path/to/minecraft-backup/backup-restic.sh -c -s minecraft -i /path/to/minecraft-server/world -o /path/to/restic-repo
``` ```
tar: /some/path/here/world/region/r.1.11.mca: file changed as we read it
``` See above for an example automating this using systemd timers.
To fix this problem, the backup script disables autosaving with the `save-off` Minecraft command before running `tar` and then re-enables autosaving after `tar` is done.
## Help ## Help
- Make sure the compression algorithm you specify is installed on your system. (zstd is not installed by default) - Make sure the compression algorithm you specify is installed on your system. (zstd is not always installed by default)
- Make sure your compression algorithm is in the crontab's PATH - 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 - 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 - It's surprising how much space backups can take--make sure you have enough empty space
- Do not put trailing `/` in the `SERVER_DIRECTORY` or `BACKUP_DIRECTORY`
- If "thin" delete method is behaving weirdly, try emptying your backup directory or switch to "sequential" - If "thin" delete method is behaving weirdly, try emptying your backup directory or switch to "sequential"
## Known Issues
There is a Minecraft bug [MC-217729](https://bugs.mojang.com/projects/MC/issues/MC-217729) in recent Minecraft server versions that cause them to automatically save the world even after receiving the `save-off` command. Until this is fixed, there is a chance that the backup will fail because the world files are modified by Minecraft in the process of creating the backup. This script will try to detect and report this problem if it does occur.
## Disclaimer ## Disclaimer
Backups are essential to the integrity of your Minecraft world. You should automate regular backups and **check that your backups work**. It is up to you to make sure that your backups work and that you have a reliable backup policy. Backups are essential to the integrity of your Minecraft world. You should automate regular backups and **check that your backups work**. It is up to you to make sure that your backups work and that you have a reliable backup policy.
Some backup tips: Some backup tips:
- Drives get corrupted or fail! Backup to a _different_ drive than the one your server is running on, so if your main drive fails then you have backups. - Drives get corrupted or fail! Backup to a _different_ drive than the one your server is running on, so if that drive fails then you have backups.
- _Automate_ backups so you never lose too much progress. - _Automate_ backups so you never lose too much progress.
- Check that your backups work from time to time. - Check that your backups work from time to time.

100
backup-restic.sh Executable file
View file

@ -0,0 +1,100 @@
#!/usr/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.
# 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
EXIT_IF_NO_SCREEN=false # Skip backup if there is no minecraft screen running
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
OPTIND=1
while getopts 'cghi:o:p:qs:v' FLAG; do
case $FLAG in
a) COMPRESSION_ALGORITHM=$OPTARG ;;
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 Script: https://github.com/nicolaschan/minecraft-backup.git"
echo "-c Enable chat messages"
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 "-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
BASE_DIR=$(dirname "$(realpath "$0")")
if ! [[ -d "$BASE_DIR/src" ]]; then
echo -e "The src/ directory needs to be in the same directory as backup.sh because it contains other scripts that backup.sh depends on.\n\
You should download the entire repository:
git clone https://github.com/nicolaschan.com/minecraft-backup.git
"
exit 1
fi
# shellcheck source=src/logging.sh
source "$BASE_DIR/src/logging.sh" \
-q "$SUPPRESS_WARNINGS" \
-v "$DEBUG"
# Check for missing encouraged arguments
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
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 1
fi
"$BASE_DIR/src/core.sh" \
"$BASE_DIR/src/exec-methods/screen.sh" \
-s "$SCREEN_NAME" \
-- \
"$BASE_DIR/src/backup-methods/restic.sh" \
-i "$SERVER_WORLD" \
-o "$BACKUP_DIRECTORY" \
-- \
-c "$ENABLE_CHAT_MESSAGES" \
-g "$EXIT_IF_NO_SCREEN" \
-p "$PREFIX" \
-q "$SUPPRESS_WARNINGS" \
-v "$DEBUG"

546
backup.sh
View file

@ -1,567 +1,121 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Minecraft server automatic backup management script # Minecraft server automatic backup management script
# by Nicolas Chan
# https://github.com/nicolaschan/minecraft-backup # https://github.com/nicolaschan/minecraft-backup
# MIT License # MIT License
# #
# For Minecraft servers running in a GNU screen, tmux, or RCON. # For Minecraft servers running in a GNU screen.
# For most convenience, run automatically with cron. # For most convenience, run automatically with cron.
# Default Configuration # Default Configuration
SCREEN_NAME="" # Name of the GNU Screen, tmux session, or hostname:port:password for RCON SCREEN_NAME="" # Name of the GNU Screen your Minecraft server is running in
SERVER_WORLDS=() # Server world directory SERVER_WORLD="" # Server world directory
BACKUP_DIRECTORY="" # Directory to save backups in 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="gzip" # Leave empty for no compression COMPRESSION_ALGORITHM="gzip" # Leave empty for no compression
EXIT_IF_NO_SCREEN=false # Skip backup if there is no minecraft screen running
COMPRESSION_FILE_EXTENSION=".gz" # Leave empty for no compression; Precede with a . (for example: ".gz") COMPRESSION_FILE_EXTENSION=".gz" # 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
PREFIX="Backup" # Shows in the chat message PREFIX="Backup" # Shows in the chat message
DEBUG=false # Enable debug messages DEBUG=false # Enable debug messages
SUPPRESS_WARNINGS=false # Suppress warnings SUPPRESS_WARNINGS=false # Suppress warnings
RESTIC_HOSTNAME="" # Leave empty to use system hostname
LOCK_FILE="" # Optional lock file to acquire to ensure two backups don't run at once
LOCK_FILE_TIMEOUT="" # Optional lock file wait timeout (in seconds)
WINDOW_MANAGER="screen" # Choices: screen, tmux, RCON
# Other Variables (do not modify) # Other Variables (do not modify)
DATE_FORMAT="%F_%H-%M-%S" DATE_FORMAT="%F_%H-%M-%S"
TIMESTAMP=$(date +$DATE_FORMAT) TIMESTAMP=$(date +$DATE_FORMAT)
log-fatal () { OPTIND=1
echo -e "\033[0;31mFATAL:\033[0m $*" while getopts 'a:bcd:e:f:ghi:l:m:o:p:qs:v' FLAG; do
}
log-warning () {
echo -e "\033[0;33mWARNING:\033[0m $*"
}
debug-log () {
if "$DEBUG"; then
echo "$1"
fi
}
while getopts 'a:cd:e:f:hH:i:l:m:o:p:qr:s:t:u:vw:x' FLAG; do
case $FLAG in case $FLAG in
a) COMPRESSION_ALGORITHM=$OPTARG ;; a) COMPRESSION_ALGORITHM=$OPTARG ;;
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" ;;
f) TIMESTAMP=$OPTARG ;; f) TIMESTAMP=$OPTARG ;;
h) echo "Minecraft Backup" g) EXIT_IF_NO_SCREEN=true ;;
echo "Repository: https://github.com/nicolaschan/minecraft-backup" h) echo "Minecraft Backup Script: https://github.com/nicolaschan/minecraft-backup.git"
echo "-a Compression algorithm (default: gzip)" echo "-a Compression algorithm (default: gzip)"
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)"
echo "-f Output file name (default is the timestamp)" 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 "-h Shows this help text"
echo "-H Set hostname for restic backup (restic only)" echo "-i Input directory (path to world folder)"
echo "-i Input directory (path to world folder, use -i once for each world)"
echo "-l Compression level (default: 3)" echo "-l Compression level (default: 3)"
echo "-m Maximum backups to keep, use -1 for unlimited (default: 128)" echo "-m Maximum backups to keep, use -1 for unlimited (default: 128)"
echo "-o Output directory" echo "-o Output directory"
echo "-p Prefix that shows in Minecraft chat (default: Backup)" echo "-p Prefix that shows in Minecraft chat (default: Backup)"
echo "-q Suppress warnings" echo "-q Suppress warnings"
echo "-r Restic repo name (if using restic)" echo "-s Minecraft server screen name"
echo "-s Screen name, tmux session name, or hostname:port:password for RCON"
echo "-t Enable lock file (lock file not used by default)"
echo "-u Lock file timeout seconds (empty = unlimited)"
echo "-v Verbose mode" echo "-v Verbose mode"
echo "-w Window manager: screen (default), tmux, RCON"
exit 0 exit 0
;; ;;
H) RESTIC_HOSTNAME=$OPTARG ;; i) SERVER_WORLD=$OPTARG ;;
i) SERVER_WORLDS+=("$OPTARG") ;;
l) COMPRESSION_LEVEL=$OPTARG ;; l) COMPRESSION_LEVEL=$OPTARG ;;
m) MAX_BACKUPS=$OPTARG ;; m) MAX_BACKUPS=$OPTARG ;;
o) BACKUP_DIRECTORY=$OPTARG ;; o) BACKUP_DIRECTORY=$OPTARG ;;
p) PREFIX=$OPTARG ;; p) PREFIX=$OPTARG ;;
q) SUPPRESS_WARNINGS=true ;; q) SUPPRESS_WARNINGS=true ;;
r) RESTIC_REPO=$OPTARG ;;
s) SCREEN_NAME=$OPTARG ;; s) SCREEN_NAME=$OPTARG ;;
t) LOCK_FILE=$OPTARG ;;
u) LOCK_FILE_TIMEOUT=$OPTARG ;;
v) DEBUG=true ;; v) DEBUG=true ;;
w) WINDOW_MANAGER=$OPTARG ;; *) ;;
*) log-fatal "Invalid option -$FLAG"; exit 1 ;;
esac esac
done done
rcon-command () { BASE_DIR=$(dirname "$(realpath "$0")")
HOST="$(echo "$1" | cut -d: -f1)"
PORT="$(echo "$1" | cut -d: -f2)"
PASSWORD="$(echo "$1" | cut -d: -f3-)"
COMMAND="$2"
reverse-hex-endian () { if ! [[ -d "$BASE_DIR/src" ]]; then
# Given a 4-byte hex integer, reverse endianness echo -e "The src/ directory needs to be in the same directory as backup.sh because it contains other scripts that backup.sh depends on.\n\
while read -r -d '' -N 8 INTEGER; do You should download the entire repository:
echo "$INTEGER" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/' git clone https://github.com/nicolaschan.com/minecraft-backup.git
done "
} exit 1
decode-hex-int () {
# decode little-endian hex integer
while read -r -d '' -N 8 INTEGER; do
BIG_ENDIAN_HEX=$(echo "$INTEGER" | reverse-hex-endian)
echo "$((16#$BIG_ENDIAN_HEX))"
done
}
stream-to-hex () {
xxd -ps
}
hex-to-stream () {
xxd -ps -r
}
encode-int () {
# Encode an integer as 4 bytes in little endian and return as hex
INT="$1"
# Source: https://stackoverflow.com/a/9955198
printf "%08x" "$INT" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/'
}
encode () {
# Encode a packet type and payload for the rcon protocol
TYPE="$1"
PAYLOAD="$2"
REQUEST_ID="$3"
PAYLOAD_LENGTH="${#PAYLOAD}"
TOTAL_LENGTH="$((4 + 4 + PAYLOAD_LENGTH + 1 + 1))"
OUTPUT=""
OUTPUT+=$(encode-int "$TOTAL_LENGTH")
OUTPUT+=$(encode-int "$REQUEST_ID")
OUTPUT+=$(encode-int "$TYPE")
OUTPUT+=$(echo -n "$PAYLOAD" | stream-to-hex)
OUTPUT+="0000"
echo -n "$OUTPUT" | hex-to-stream
}
read-response () {
# read next response packet and return the payload text
HEX_LENGTH=$(head -c4 <&3 | stream-to-hex | reverse-hex-endian)
LENGTH=$((16#$HEX_LENGTH))
RESPONSE_PAYLOAD=$(head -c $LENGTH <&3 | stream-to-hex)
echo -n "$RESPONSE_PAYLOAD"
}
response-request-id () {
echo -n "${1:0:8}" | decode-hex-int
}
response-type () {
echo -n "${1:8:8}" | decode-hex-int
}
response-payload () {
echo -n "${1:16:-4}" | hex-to-stream
}
login () {
PASSWORD="$1"
encode 3 "$PASSWORD" 12 >&3
RESPONSE=$(read-response "$IN_PIPE")
RESPONSE_REQUEST_ID=$(response-request-id "$RESPONSE")
if [[ "$RESPONSE_REQUEST_ID" == "-1" ]] || [[ "$RESPONSE_REQUEST_ID" == "4294967295" ]]; then
log-warning "RCON connection failed: Wrong RCON password" 1>&2
return 1
fi
}
run-command () {
COMMAND="$1"
# encode 2 "$COMMAND" 13 >> "$OUT_PIPE"
encode 2 "$COMMAND" 13 >&3
RESPONSE=$(read-response "$IN_PIPE")
response-payload "$RESPONSE"
}
# Open a TCP socket
# Source: https://www.xmodulo.com/tcp-udp-socket-bash-shell.html
if ! exec 3<>/dev/tcp/"$HOST"/"$PORT"; then
log-warning "RCON connection failed: Could not connect to $HOST:$PORT"
return 1
fi
login "$PASSWORD" || return 1
debug-log "$(run-command "$COMMAND")"
# Close the socket
exec 3<&-
exec 3>&-
}
if ! "$DEBUG"; then
QUIET="-q"
else
QUIET=""
fi fi
if [[ "$COMPRESSION_FILE_EXTENSION" == "." ]]; then # shellcheck source=src/logging.sh
COMPRESSION_FILE_EXTENSION="" source "$BASE_DIR/src/logging.sh" \
fi -q "$SUPPRESS_WARNINGS" \
-v "$DEBUG"
# Check for missing encouraged arguments # Check for missing encouraged arguments
if ! $SUPPRESS_WARNINGS; then if [[ $SCREEN_NAME == "" ]]; then
if [[ "$SCREEN_NAME" == "" ]]; then log-warning "Minecraft screen name not specified (use -s)"
log-warning "Minecraft screen/tmux/rcon location not specified (use -s)"
fi
fi fi
# Check for required arguments # Check for required arguments
MISSING_CONFIGURATION=false MISSING_CONFIGURATION=false
if [[ "${#SERVER_WORLDS[@]}" == "0" ]]; then if [[ $SERVER_WORLD == "" ]]; then
log-fatal "Server world not specified (use -i)" log-fatal "Server world not specified (use -i)"
MISSING_CONFIGURATION=true MISSING_CONFIGURATION=true
fi fi
if [[ "$BACKUP_DIRECTORY" == "" ]] && [[ "$RESTIC_REPO" == "" ]]; then if [[ $BACKUP_DIRECTORY == "" ]]; then
log-fatal "Backup location not specified (use -o or -r)" log-fatal "Backup directory not specified (use -o)"
MISSING_CONFIGURATION=true MISSING_CONFIGURATION=true
fi fi
if [[ "$RESTIC_REPO" != "" ]]; then
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
log-fatal "Both output directory (-o) and restic repo (-r) specified but only one may be used at a time"
MISSING_CONFIGURATION=true
fi
if [[ $MAX_BACKUPS -ge 0 ]] && [[ $MAX_BACKUPS -lt 70 ]] && [[ $DELETE_METHOD == "thin" ]]; then
log-fatal "Thinning delete with restic requires at least 70 snapshots to be kept. If you need to keep fewer than 70, use sequential delete."
MISSING_CONFIGURATION=true
fi
fi
if $MISSING_CONFIGURATION; then if $MISSING_CONFIGURATION; then
exit 1 exit 1
fi fi
if [[ "$BACKUP_DIRECTORY" != "" ]]; then "$BASE_DIR/src/core.sh" \
ARCHIVE_FILE_NAME="$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION" "$BASE_DIR/src/exec-methods/screen.sh" \
ARCHIVE_PATH="$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME" -s "$SCREEN_NAME" \
fi -- \
if [[ "$RESTIC_REPO" != "" ]]; then "$BASE_DIR/src/backup-methods/tar.sh" \
ARCHIVE_PATH="$RESTIC_REPO $TIMESTAMP" -a "$COMPRESSION_ALGORITHM" \
fi -d "$DELETE_METHOD" \
-e "$COMPRESSION_FILE_EXTENSION" \
# Minecraft server screen interface functions -f "$TIMESTAMP" \
message-players () { -i "$SERVER_WORLD" \
local MESSAGE=$1 -l "$COMPRESSION_LEVEL" \
local HOVER_MESSAGE=$2 -m "$MAX_BACKUPS" \
message-players-color "$MESSAGE" "$HOVER_MESSAGE" "gray" -o "$BACKUP_DIRECTORY" \
} -- \
execute-command () { -c "$ENABLE_CHAT_MESSAGES" \
local COMMAND=$1 -g "$EXIT_IF_NO_SCREEN" \
if [[ $SCREEN_NAME != "" ]]; then -p "$PREFIX" \
case $WINDOW_MANAGER in -q "$SUPPRESS_WARNINGS" \
"screen") screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)" -v "$DEBUG"
;;
"tmux") tmux send-keys -t "$SCREEN_NAME" "$COMMAND" ENTER
;;
"RCON"|"rcon") rcon-command "$SCREEN_NAME" "$COMMAND"
;;
"docker-rcon") docker exec "$SCREEN_NAME" rcon-cli "$COMMAND"
;;
esac
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
debug-log "$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
}
# 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 "$BACKUP_DIRECTORY"/"$BACKUP"
message-players "Deleted old backup" "$BACKUP"
}
# Sequential delete method
delete-sequentially () {
local BACKUPS=("$BACKUP_DIRECTORY"/*) # List oldest first
while [[ $MAX_BACKUPS -ge 0 && ${#BACKUPS[@]} -gt $MAX_BACKUPS ]]; do
delete-backup "$(basename "${BACKUPS[0]}")"
BACKUPS=("$BACKUP_DIRECTORY"/*)
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"
}
# Given two exit codes, print a nonzero one if there is one
exit-code () {
if [[ "$1" != "0" ]]; then
echo "$1"
else
if [[ "$2" == "" ]]; then
echo 0
else
echo "$2"
fi
fi
}
# 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 [[ $MAX_BACKUPS != -1 ]] && [[ $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=("$BACKUP_DIRECTORY"/*) # Oldest first
local NUM_BACKUPS="${#BACKUPS[@]}"
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=$((NUM_BACKUPS - 1 - (BLOCK_SIZE + CURRENT_INDEX))) # Not an off-by-one error because a new backup was already saved
if [ "$OLDEST_BACKUP_IN_BLOCK_INDEX" -lt 0 ]; then
break;
fi
local OLDEST_BACKUP_IN_BLOCK
OLDEST_BACKUP_IN_BLOCK="$(basename "${BACKUPS[OLDEST_BACKUP_IN_BLOCK_INDEX]}")"
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
debug-log "$OLDEST_BACKUP_IN_BLOCK promoted to next block"
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-restic-sequential () {
if [ "$MAX_BACKUPS" -ge 0 ]; then
restic forget -r "$RESTIC_REPO" --keep-last "$MAX_BACKUPS" "$QUIET"
fi
}
delete-restic-thinning () {
if [ "$MAX_BACKUPS" -ge 70 ]; then
# MAX_BACKUPS >= 70
restic forget -r "$RESTIC_REPO" --keep-last 16 --keep-hourly 24 --keep-daily 30 --keep-weekly $((MAX_BACKUPS - 70)) "$QUIET"
else
# We have a check that MAX_BACKUPS is not 70 > MAX_BACKUPS >= 0, so we can assume here it is negative
# Negative means don't delete old snapshots
restic forget -r "$RESTIC_REPO" --keep-last 16 --keep-hourly 24 --keep-daily 30 --keep-weekly 9999999 "$QUIET"
fi
}
# Delete old backups
delete-old-backups () {
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
case $DELETE_METHOD in
"sequential") delete-sequentially
;;
"thin") delete-thinning
;;
esac
fi
if [[ "$RESTIC_REPO" != "" ]]; then
case $DELETE_METHOD in
"sequential") delete-restic-sequential
;;
"thin") delete-restic-thinning
;;
esac
fi
}
clean-up () {
# Re-enable world autosaving
execute-command "save-on"
# Save the world
execute-command "save-all"
TIME_DELTA=$((END_TIME - START_TIME))
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
WORLD_SIZE_BYTES=$(du --bytes --total --max-depth=0 "${SERVER_WORLDS[@]}" | tail -n 1 | awk '{print $1}')
ARCHIVE_SIZE_BYTES=$(du -b "$ARCHIVE_PATH" | awk '{print $1}')
ARCHIVE_SIZE=$(du -h "$ARCHIVE_PATH" | awk '{print $1}')
BACKUP_DIRECTORY_SIZE=$(du -h --max-depth=0 "$BACKUP_DIRECTORY" | awk '{print $1}')
# Check that archive size is not null and at least 200 Bytes
if [[ "$ARCHIVE_EXIT_CODE" == "0" && "$WORLD_SIZE_BYTES" -gt 0 && "$ARCHIVE_SIZE" != "" && "$ARCHIVE_SIZE_BYTES" -gt 200 ]]; then
# Notify players of completion
COMPRESSION_PERCENT=$((ARCHIVE_SIZE_BYTES * 100 / WORLD_SIZE_BYTES))
message-players-success "Backup complete!" "$TIME_DELTA s, $ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%"
delete-old-backups
exit 0
else
rm "$ARCHIVE_PATH" # Delete bad archive so we can't fill up with bad archives
message-players-error "Backup was not saved!" "Please notify an administrator"
exit 1
fi
fi
if [[ "$RESTIC_REPO" != "" ]]; then
if [[ "$ARCHIVE_EXIT_CODE" == "0" ]]; then
message-players-success "Backup complete!" "$TIME_DELTA s"
delete-old-backups
exit 0
else
message-players-error "Backup was not saved!" "Please notify an administrator"
exit 1
fi
fi
}
trap "clean-up" 2
do-backup () {
# Notify players of start
message-players "Starting backup..." "$ARCHIVE_PATH"
# Disable world autosaving
execute-command "save-off"
# Backup world
START_TIME=$(date +"%s")
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
# Ensure backup directory exists
mkdir -p "$(dirname "$ARCHIVE_PATH")"
case $COMPRESSION_ALGORITHM in
# No compression
"") tar -cf "$ARCHIVE_PATH" "${SERVER_WORLDS[@]}"
;;
# With compression
*) tar -cf - "${SERVER_WORLDS[@]}" | $COMPRESSION_ALGORITHM -cv -"$COMPRESSION_LEVEL" - > "$ARCHIVE_PATH" 2>> /dev/null
;;
esac
EXIT_CODES=("${PIPESTATUS[@]}")
# tar exit codes: http://www.gnu.org/software/tar/manual/html_section/Synopsis.html
# 0 = successful, 1 = some files differ, 2 = fatal
if [ "${EXIT_CODES[0]}" == "1" ]; then
log-warning "Some files may differ in the backup archive (file changed as read)"
TAR_EXIT_CODE="0"
else
TAR_EXIT_CODE="${EXIT_CODES[0]}"
fi
ARCHIVE_EXIT_CODE="$(exit-code "$TAR_EXIT_CODE" "${EXIT_CODES[1]}")"
if [ "$ARCHIVE_EXIT_CODE" -ne 0 ]; then
log-fatal "Archive command exited with nonzero exit code $ARCHIVE_EXIT_CODE"
fi
fi
if [[ "$RESTIC_REPO" != "" ]]; then
RESTIC_TIMESTAMP="${TIMESTAMP:0:10} ${TIMESTAMP:11:2}:${TIMESTAMP:14:2}:${TIMESTAMP:17:2}"
if [[ "$RESTIC_HOSTNAME" == "" ]]; then
RESTIC_HOSTNAME_OPTION=()
else
RESTIC_HOSTNAME_OPTION=("--host" "$RESTIC_HOSTNAME")
fi
restic backup -r "$RESTIC_REPO" "${SERVER_WORLDS[@]}" --time "$RESTIC_TIMESTAMP" "$QUIET" "${RESTIC_HOSTNAME_OPTION[@]}"
ARCHIVE_EXIT_CODE=$?
if [ "$ARCHIVE_EXIT_CODE" -eq 3 ]; then
log-warning "Incomplete snapshot taken (some files could not be read)"
ARCHIVE_EXIT_CODE="0"
else
if [ "$ARCHIVE_EXIT_CODE" -ne 0 ]; then
# According to the restic docs, exit code is either 0, 1, or 3
# Exit code 1 means fatal
# See: https://restic.readthedocs.io/en/latest/040_backup.html
log-fatal "No restic snapshot created (exit code $ARCHIVE_EXIT_CODE)"
fi
fi
fi
sync
END_TIME=$(date +"%s")
clean-up
}
if [[ "$LOCK_FILE" != "" ]]; then
TIMEOUT_OPTION=()
if [[ "$LOCK_FILE_TIMEOUT" != "" ]]; then
TIMEOUT_OPTION=("-w" "$LOCK_FILE_TIMEOUT")
fi
(if ! flock "${TIMEOUT_OPTION[@]}" --no-fork 200; then
log-fatal "Could not acquire lock on lock file: $LOCK_FILE"
exit 1
fi
do-backup) 200>"$LOCK_FILE"
else
do-backup
fi

61
flake.lock generated
View file

@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1725634671,
"narHash": "sha256-v3rIhsJBOMLR8e/RNWxr828tB+WywYIoajrZKFM+0Gg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "574d1eac1c200690e27b8eb4e24887f8df7ac27c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,36 +0,0 @@
{
description = "A flake for bash, coreutils, xxd, restic, util-linux, and openssh";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
bash
coreutils
kcov
vim # provides xxd
python3
python312Packages.fusepy
restic
screen
shellcheck
tmux
utillinux
openssh
];
};
}
);
}

119
rcon.sh
View file

@ -1,119 +0,0 @@
#!/usr/bin/env bash
rcon-command () {
HOST="$(echo "$1" | cut -d: -f1)"
PORT="$(echo "$1" | cut -d: -f2)"
PASSWORD="$(echo "$1" | cut -d: -f3-)"
COMMAND="$2"
reverse-hex-endian () {
# Given a 4-byte hex integer, reverse endianness
while read -r -d '' -N 8 INTEGER; do
echo "$INTEGER" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/'
done
}
decode-hex-int () {
# decode little-endian hex integer
while read -r -d '' -N 8 INTEGER; do
BIG_ENDIAN_HEX=$(echo "$INTEGER" | reverse-hex-endian)
echo "$((16#$BIG_ENDIAN_HEX))"
done
}
stream-to-hex () {
xxd -ps
}
hex-to-stream () {
xxd -ps -r
}
encode-int () {
# Encode an integer as 4 bytes in little endian and return as hex
INT="$1"
# Source: https://stackoverflow.com/a/9955198
printf "%08x" "$INT" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/'
}
encode () {
# Encode a packet type and payload for the rcon protocol
TYPE="$1"
PAYLOAD="$2"
REQUEST_ID="$3"
PAYLOAD_LENGTH="${#PAYLOAD}"
TOTAL_LENGTH="$((4 + 4 + PAYLOAD_LENGTH + 1 + 1))"
OUTPUT=""
OUTPUT+=$(encode-int "$TOTAL_LENGTH")
OUTPUT+=$(encode-int "$REQUEST_ID")
OUTPUT+=$(encode-int "$TYPE")
OUTPUT+=$(echo -n "$PAYLOAD" | stream-to-hex)
OUTPUT+="0000"
echo -n "$OUTPUT" | hex-to-stream
}
read-response () {
# read next response packet and return the payload text
HEX_LENGTH=$(head -c4 <&3 | stream-to-hex | reverse-hex-endian)
LENGTH=$((16#$HEX_LENGTH))
RESPONSE_PAYLOAD=$(head -c $LENGTH <&3 | stream-to-hex)
echo -n "$RESPONSE_PAYLOAD"
}
response-request-id () {
echo -n "${1:0:8}" | decode-hex-int
}
response-type () {
echo -n "${1:8:8}" | decode-hex-int
}
response-payload () {
echo -n "${1:16:-4}" | hex-to-stream
}
login () {
PASSWORD="$1"
encode 3 "$PASSWORD" 12 >&3
RESPONSE=$(read-response "$IN_PIPE")
RESPONSE_REQUEST_ID=$(response-request-id "$RESPONSE")
if [ "$RESPONSE_REQUEST_ID" -eq -1 ] || [ "$RESPONSE_REQUEST_ID" -eq 4294967295 ]; then
echo "Authentication failed: Wrong RCON password" 1>&2
return 1
fi
}
run-command () {
COMMAND="$1"
# encode 2 "$COMMAND" 13 >> "$OUT_PIPE"
encode 2 "$COMMAND" 13 >&3
RESPONSE=$(read-response "$IN_PIPE")
response-payload "$RESPONSE"
}
# Open a TCP socket
# Source: https://www.xmodulo.com/tcp-udp-socket-bash-shell.html
exec 3<>/dev/tcp/"$HOST"/"$PORT"
login "$PASSWORD" || return 1
run-command "$COMMAND"
# Close the socket
exec 3<&-
exec 3>&-
}
HOST="$1"
PORT="$2"
PASSWORD="$3"
COMMAND="$4"
rcon-command "$HOST:$PORT:$PASSWORD" "$COMMAND"

27
src/backup-methods/restic.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/env bash
# Backup to restic
OPTIND=1
while getopts 'i:o:' FLAG; do
case $FLAG in
i) SERVER_WORLD=$OPTARG ;;
o) RESTIC_REPO=$OPTARG ;;
*) ;;
esac
done
minecraft-backup-backup () {
restic backup -r "$RESTIC_REPO" "$SERVER_WORLD"
}
minecraft-backup-check () {
local WORLD_SIZE_BYTES
WORLD_SIZE_BYTES=$(du -b --max-depth=0 "$SERVER_WORLD" | awk '{print $1}')
echo "$WORLD_SIZE_BYTES"
}
minecraft-backup-epilog () {
# do nothing
echo -n
}

188
src/backup-methods/tar.sh Executable file
View file

@ -0,0 +1,188 @@
#!/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 () {
echo "$1" | awk -F_ '{gsub(/-/,":",$2); print $1" "$2}'
}
# Delete a backup
delete-backup () {
local BACKUP=$1
rm -f "$BACKUP_DIRECTORY/$BACKUP"
echo "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
# List oldest first
# shellcheck disable=SC2207
IFS=$'\n' BACKUPS=($(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
# List oldest first
# shellcheck disable=SC2207
IFS=$'\n' BACKUPS=($(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
# List newest first
# shellcheck disable=SC2207
IFS=$'\n' BACKUPS=($(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}")
if ! $BLOCK_FUNCTION "$OLDEST_BACKUP_TIMESTAMP"; then
# 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
}

181
src/core.sh Executable file
View file

@ -0,0 +1,181 @@
#!/usr/bin/env bash
# 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
OPTIND=1
while getopts 'c:g:p:q:v:' FLAG; do
case $FLAG in
c) ENABLE_CHAT_MESSAGES=$OPTARG ;;
g) EXIT_IF_NO_SCREEN=$OPTARG ;;
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
SHORT_BACKUP_METHOD=$(basename "$BACKUP_METHOD")
message-players "Starting backup..." "$SHORT_BACKUP_METHOD ${BACKUP_METHOD_OPTIONS[*]}"
# 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
BACKUP_RESULT=$?
END_TIME=$(date +"%s")
clean-up
TIME_DELTA=$((END_TIME - START_TIME))
CHECK_MESSAGE=$(minecraft-backup-check)
CHECK_RESULT=$?
if [[ $BACKUP_RESULT != "0" ]] || [[ $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
View 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
}

47
src/logging.sh Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
SUPPRESS_WARNINGS=false
DEBUG=true
OPTIND=1
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
}

23
test.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/bash
START_TIMESTAMP=1501484400
ITERATIONS=1000
MINUTE_INTERVAL=30
MINUTES_SINCE_START=0
WORLD=test/world
BACKUP_DIR=test/backups
mkdir -p $WORLD
echo "hello there" > "$WORLD/content.txt"
if [[ $1 != "" ]]; then
ITERATIONS=$1
fi
for (( c=1; c<=ITERATIONS; c++ )); do
TIMESTAMP=$(( START_TIMESTAMP + MINUTES_SINCE_START * 60 ))
FILE_NAME=$(date -d "@$TIMESTAMP" +%F_%H-%M-%S)
./backup.sh -q -s minecraft -i "$WORLD" -o "$BACKUP_DIR" -f "$FILE_NAME"
(( MINUTES_SINCE_START += MINUTE_INTERVAL ))
done

1
test/.gitignore vendored
View file

@ -1 +0,0 @@
tmp

View file

@ -1,5 +0,0 @@
tellraw @a ["",{"text":"[Backup] ","color":"gray","italic":true},{"text":"Starting backup...","color":"gray","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"test/tmp/backups/2021-01-01_00-00-00.tar.gz"}]}}}]
save-off
save-on
save-all
tellraw @a ["",{"text":"[Backup] ","color":"gray","italic":true},{"text":"Backup complete!","color":"green","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"0 s, 4.0K/4.0K, 316%"}]}}}]

View file

@ -1,5 +0,0 @@
tellraw @a ["",{"text":"[Hello] ","color":"gray","italic":true},{"text":"Starting backup...","color":"gray","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"test/tmp/backups/2021-01-01_00-00-00.tar.gz"}]}}}]
save-off
save-on
save-all
tellraw @a ["",{"text":"[Hello] ","color":"gray","italic":true},{"text":"Hello complete!","color":"green","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"0 s, 4.0K/4.0K, 316%"}]}}}]

View file

@ -1,31 +0,0 @@
# Reference: https://docs.python.org/3/library/socketserver.html
import codecs
import socketserver
import sys
class Handler(socketserver.BaseRequestHandler):
def handle(self):
while True:
length = int.from_bytes(self.request.recv(4), 'little')
if not length:
continue
self.request.recv(4)
type_ = int.from_bytes(self.request.recv(4), 'little')
self.data = self.request.recv(length - 8)[:-2].decode('utf-8')
if self.data:
if type_ == 2:
print(self.data)
sys.stdout.flush()
try:
if type_ == 3 and self.data != sys.argv[2]:
self.request.sendall(codecs.decode('0a000000ffffffff020000000000', 'hex'))
else:
self.request.sendall(codecs.decode('0a00000010000000020000000000', 'hex'))
except:
break
if __name__ == "__main__":
HOST, PORT = "localhost", int(sys.argv[1])
with socketserver.ThreadingTCPServer((HOST, PORT), Handler) as server:
server.serve_forever()

@ -1 +0,0 @@
Subproject commit ebc4baa08f045b7ef0f45c4b7d6f34f08d732f3d

View file

@ -1,578 +0,0 @@
#!/usr/bin/env bash
# Helper functions
TEST_DIR="test"
TEST_TMP="$TEST_DIR/tmp"
SCREEN_TMP="tmp-screen"
RCON_PORT="8088"
RCON_PASSWORD="supersecret"
export RESTIC_PASSWORD="restic-pass-secret"
setUp () {
chmod -R 755 "$TEST_TMP" || true
rm -rf "$TEST_TMP"
mkdir -p "$TEST_TMP/server/world"
mkdir -p "$TEST_TMP/backups"
echo "file1" > "$TEST_TMP/server/world/file1.txt"
echo "file2" > "$TEST_TMP/server/world/file2.txt"
echo "file3" > "$TEST_TMP/server/world/file3.txt"
restic init -r "$TEST_TMP/backups-restic" -q
screen -dmS "$SCREEN_TMP" bash
while ! screen -S "$SCREEN_TMP" -Q "select" . &>/dev/null; do
sleep 0.1
done
screen -S "$SCREEN_TMP" -X stuff "cat > $TEST_TMP/screen-output\n"
tmux new-session -d -s "$SCREEN_TMP"
while ! tmux has-session -t "$SCREEN_TMP" 2>/dev/null; do
sleep 0.1
done
tmux send-keys -t "$SCREEN_TMP" "cat > $TEST_TMP/tmux-output" ENTER
python test/mock_rcon.py "$RCON_PORT" "$RCON_PASSWORD" > "$TEST_TMP/rcon-output" &
echo "$!" > "$TEST_TMP/rcon-pid"
while ! [[ (-f "$TEST_TMP/screen-output") && (-f "$TEST_TMP/tmux-output") && (-f "$TEST_TMP/rcon-output") ]]; do
sleep 0.1
done
}
tearDown () {
RCON_PID="$(cat "$TEST_TMP/rcon-pid")"
kill "$RCON_PID" >/dev/null 2>&1 || true
screen -S "$SCREEN_TMP" -X quit >/dev/null 2>&1 || true
tmux kill-session -t "$SCREEN_TMP" >/dev/null 2>&1 || true
sleep 0.1
}
assert-equals-directory () {
if ! [ -e "$1" ]; then
fail "File not found: $1"
fi
if [ -d "$1" ]; then
for FILE in "$1"/*; do
assert-equals-directory "$FILE" "$2/${FILE##"$1"}"
done
else
assertEquals "$(cat "$1")" "$(cat "$2")"
fi
}
check-backup-full-paths () {
BACKUP_ARCHIVE="$1"
WORLD_DIR="$2"
mkdir -p "$TEST_TMP/restored"
tar --extract --file "$BACKUP_ARCHIVE" --directory "$TEST_TMP/restored"
assert-equals-directory "$WORLD_DIR" "$TEST_TMP/restored/$WORLD_DIR"
rm -rf "$TEST_TMP/restored"
}
check-backup () {
BACKUP_ARCHIVE="$1"
check-backup-full-paths "$TEST_TMP/backups/$BACKUP_ARCHIVE" "$TEST_TMP/server/world"
}
check-latest-backup-restic () {
WORLD_DIR="$TEST_TMP/server/world"
restic restore latest -r "$TEST_TMP/backups-restic" --target "$TEST_TMP/restored" -q
assert-equals-directory "$WORLD_DIR" "$TEST_TMP/restored/$WORLD_DIR"
rm -rf "$TEST_TMP/restored"
}
# Tests
test-restic-explicit-hostname () {
EXPECTED_HOSTNAME="${HOSTNAME}blahblah"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP" -H "$EXPECTED_HOSTNAME"
check-latest-backup-restic
LATEST_BACKUP_HOSTNAME=$(restic -r "$TEST_TMP/backups-restic" snapshots latest --json | jq -r '.[0]["hostname"]')
assertEquals "$EXPECTED_HOSTNAME" "$LATEST_BACKUP_HOSTNAME"
}
test-restic-default-hostname () {
EXPECTED_HOSTNAME="${HOSTNAME}"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-latest-backup-restic
LATEST_BACKUP_HOSTNAME=$(restic -r "$TEST_TMP/backups-restic" snapshots latest --json | jq -r '.[0]["hostname"]')
assertEquals "$EXPECTED_HOSTNAME" "$LATEST_BACKUP_HOSTNAME"
}
test-backup-defaults () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.gz"
}
test-backup-multiple-worlds () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
cp -r "$TEST_TMP/server/world" "$TEST_TMP/server/world_nether"
cp -r "$TEST_TMP/server/world" "$TEST_TMP/server/world_the_end"
./backup.sh -i "$TEST_TMP/server/world" -i "$TEST_TMP/server/world_nether" -i "$TEST_TMP/server/world_the_end" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
mkdir -p "$TEST_TMP/restored"
tar --extract --file "$TEST_TMP/backups/$TIMESTAMP.tar.gz" --directory "$TEST_TMP/restored"
assert-equals-directory "$TEST_TMP/server/world" "$TEST_TMP/restored/$TEST_TMP/server/world"
assert-equals-directory "$TEST_TMP/server/world_nether" "$TEST_TMP/restored/$TEST_TMP/server/world_nether"
assert-equals-directory "$TEST_TMP/server/world_the_end" "$TEST_TMP/restored/$TEST_TMP/server/world_the_end"
}
test-file-changed-as-read-warning () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
dd if=/dev/urandom of="$TEST_TMP/server/world/random" &
DD_PID="$!"
OUTPUT="$(./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
kill "$DD_PID"
assertEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "Some files may differ in the backup archive"
# Check that the backup actually resulted in a valid tar
assertTrue '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
mkdir -p "$TEST_TMP/restored"
tar --extract --file "$TEST_TMP/backups/$TIMESTAMP.tar.gz" --directory "$TEST_TMP/restored"
assert-equals-directory "$WORLD_DIR/file1.txt" "$TEST_TMP/restored/$WORLD_DIR/file1.txt"
assert-equals-directory "$WORLD_DIR/file2.txt" "$TEST_TMP/restored/$WORLD_DIR/file2.txt"
assert-equals-directory "$WORLD_DIR/file3.txt" "$TEST_TMP/restored/$WORLD_DIR/file3.txt"
}
test-lock-defaults () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -t "$TEST_TMP/lockfile" -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.gz"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +1 hour")"
./backup.sh -t "$TEST_TMP/lockfile" -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.gz"
}
test-lock-timeout () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
flock "$TEST_TMP/lockfile" sleep 10 &
OUTPUT=$(./backup.sh -t "$TEST_TMP/lockfile" -u 0 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP")
assertNotEquals 0 "$?"
assertContains "$OUTPUT" "Could not acquire lock on lock file: $TEST_TMP/lockfile"
}
test-restic-incomplete-snapshot () {
chmod 000 "$TEST_TMP/server/world/file1.txt"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP")"
assertEquals 1 "$(restic list snapshots -r "$TEST_TMP/backups-restic" | wc -l)"
assertContains "$OUTPUT" "Incomplete snapshot taken"
}
test-restic-no-snapshot () {
rm -rf "$TEST_TMP/server"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP")"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "No restic snapshot created"
}
test-restic-thinning-too-few () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -m 10 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "Thinning delete with restic requires at least 70 snapshots to be kept."
}
test-restic-thinning-delete-long () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i day")"
./backup.sh -m -1 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
EXPECTED_TIMESTAMPS=(
# Weekly
"2021-01-03 00:00:00"
"2021-01-10 00:00:00"
"2021-01-17 00:00:00"
"2021-01-24 00:00:00"
"2021-01-31 00:00:00"
# Daily (30)
"2021-03-13 00:00:00"
"2021-03-14 00:00:00"
"2021-03-15 00:00:00"
"2021-03-16 00:00:00"
"2021-03-17 00:00:00"
"2021-03-18 00:00:00"
# Hourly (24)
"2021-03-19 00:00:00"
"2021-03-20 00:00:00"
"2021-03-21 00:00:00"
"2021-03-22 00:00:00"
"2021-03-23 00:00:00"
"2021-03-24 00:00:00"
"2021-03-25 00:00:00"
"2021-03-26 00:00:00"
# Sub-hourly (16)
"2021-03-26 00:00:00"
"2021-03-27 00:00:00"
"2021-03-28 00:00:00"
"2021-03-29 00:00:00"
"2021-03-30 00:00:00"
"2021-03-31 00:00:00"
"2021-04-01 00:00:00"
"2021-04-02 00:00:00"
"2021-04-03 00:00:00"
"2021-04-04 00:00:00"
"2021-04-05 00:00:00"
"2021-04-06 00:00:00"
"2021-04-07 00:00:00"
"2021-04-08 00:00:00"
"2021-04-09 00:00:00"
"2021-04-10 00:00:00"
)
SNAPSHOTS="$(restic snapshots -r "$TEST_TMP/backups-restic")"
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
assertContains "$SNAPSHOTS" "$TIMESTAMP"
done
}
test-restic-defaults () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-latest-backup-restic
}
test-backup-spaces-in-directory () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
WORLD_SPACES="$TEST_TMP/minecraft server/the world"
mkdir -p "$(dirname "$WORLD_SPACES")"
BACKUP_SPACES="$TEST_TMP/My Backups"
mkdir -p "$BACKUP_SPACES"
cp -r "$TEST_TMP/server/world" "$WORLD_SPACES"
./backup.sh -i "$WORLD_SPACES" -o "$BACKUP_SPACES" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup-full-paths "$BACKUP_SPACES/$TIMESTAMP.tar.gz" "$WORLD_SPACES"
}
test-backup-no-compression () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -a "" -e "" -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar"
}
test-backup-max-compression () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -a "xz" -e "xz" -l 9e -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.xz"
}
test-chat-messages () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -c -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_OUTPUT="$(head -n-1 "$TEST_DIR/data/test-chat-messages.txt")"
ACTUAL_OUTPUT="$(head -n-1 "$TEST_TMP/screen-output")"
assertEquals "$EXPECTED_OUTPUT" "$ACTUAL_OUTPUT"
}
test-chat-prefix () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -p "Hello" -c -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_OUTPUT="$(head -n-1 "$TEST_DIR/data/test-chat-prefix.txt")"
ACTUAL_OUTPUT="$(head -n-1 "$TEST_TMP/screen-output")"
assertEquals "$EXPECTED_OUTPUT" "$ACTUAL_OUTPUT"
}
test-check-help () {
HELP_HEADER="$(./backup.sh -h)"
assertEquals "Minecraft Backup" "$(head -n1 <<< "$HELP_HEADER")"
}
test-missing-options () {
OUTPUT="$(./backup.sh 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertContains "$OUTPUT" "Minecraft screen/tmux/rcon location not specified (use -s)"
assertContains "$OUTPUT" "Server world not specified"
assertContains "$OUTPUT" "Backup location not specified"
}
test-restic-and-output-options () {
OUTPUT="$(./backup.sh -c -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" -r "$TEST_TMP/backups-restic" 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertContains "$OUTPUT" "Both output directory (-o) and restic repo (-r) specified but only one may be used at a time"
}
test-missing-options-suppress-warnings () {
OUTPUT="$(./backup.sh -q 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertNotContains "$OUTPUT" "Minecraft screen/tmux/rcon location not specified (use -s)"
}
test-invalid-options () {
OUTPUT="$(./backup.sh -z 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertContains "$OUTPUT" "Invalid option"
}
test-empty-world-warning () {
mkdir -p "$TEST_TMP/server/empty-world"
OUTPUT="$(./backup.sh -v -i "$TEST_TMP/server/empty-world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
assertContains "$OUTPUT" "Backup was not saved!"
}
test-block-size-warning () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -m 10 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertContains "$OUTPUT" "is smaller than TOTAL_BLOCK_SIZE"
}
test-bad-input-world () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -m 10 -i "$TEST_TMP/server/notworld" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertFalse '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
}
test-nonzero-exit-warning () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -a _BLAH_ -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "Archive command exited with nonzero exit code"
assertFalse '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
}
test-screen-interface () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_CONTENTS=$(echo -e "save-off\nsave-on\nsave-all")
SCREEN_CONTENTS="$(cat "$TEST_TMP/screen-output")"
assertEquals "$EXPECTED_CONTENTS" "$SCREEN_CONTENTS"
}
test-tmux-interface () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -w tmux -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_CONTENTS=$(echo -e "save-off\nsave-on\nsave-all")
SCREEN_CONTENTS="$(cat "$TEST_TMP/tmux-output")"
assertEquals "$EXPECTED_CONTENTS" "$SCREEN_CONTENTS"
}
test-rcon-interface () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -w rcon -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "localhost:$RCON_PORT:$RCON_PASSWORD" -f "$TIMESTAMP"
EXPECTED_CONTENTS=$(echo -e "save-off\nsave-on\nsave-all")
SCREEN_CONTENTS="$(head -n3 "$TEST_TMP/rcon-output")"
assertEquals "$EXPECTED_CONTENTS" "$SCREEN_CONTENTS"
}
test-rcon-interface-wrong-password () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -w RCON -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "localhost:$RCON_PORT:wrong$RCON_PASSWORD" -f "$TIMESTAMP" 2>&1)"
assertContains "$OUTPUT" "Wrong RCON password"
}
test-rcon-interface-not-running () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -w RCON -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "@!@#:$RCON_PORT:$RCON_PASSWORD" -f "$TIMESTAMP" 2>&1)"
assertContains "$OUTPUT" "Could not connect"
}
test-docker-rcon () {
CONTAINER="$(docker run -d -e EULA=TRUE docker.io/itzg/minecraft-server)"
while ! docker exec "$CONTAINER" grep 'RCON running on 0.0.0.0:25575' /data/logs/latest.log; do
sleep 0.1
done
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -w docker-rcon -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$CONTAINER" -f "$TIMESTAMP"
OUTPUT="$(docker exec "$CONTAINER" cat /data/logs/latest.log)"
docker rm -f "$CONTAINER"
assertContains "$OUTPUT" "[Rcon: Automatic saving is now disabled]"
assertContains "$OUTPUT" "[Rcon: Automatic saving is now enabled]"
assertContains "$OUTPUT" "[Rcon: Saved the game]"
}
test-sequential-delete () {
for i in $(seq 0 20); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -d "sequential" -m 30 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
for i in $(seq 20 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -d "sequential" -m 10 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
for i in $(seq 90 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
check-backup "$TIMESTAMP.tar.gz"
done
assertEquals 10 "$(find "$TEST_TMP/backups" -type f | wc -l)"
}
test-restic-sequential-delete () {
for i in $(seq 0 20); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -d "sequential" -m 10 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
assertEquals 10 "$(restic list snapshots -r "$TEST_TMP/backups-restic" | wc -l)"
check-latest-backup-restic
SNAPSHOTS="$(restic snapshots -r "$TEST_TMP/backups-restic")"
for i in $(seq 11 20); do
TIMESTAMP="$(date "+%F %H:%M:%S" --date="2021-01-01 +$i hour")"
assertContains "$SNAPSHOTS" "$TIMESTAMP"
done
for i in $(seq 0 10); do
TIMESTAMP="$(date "+%F %H:%M:%S" --date="2021-01-01 +$i hour")"
assertNotContains "$SNAPSHOTS" "$TIMESTAMP"
done
}
test-thinning-delete () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
EXPECTED_TIMESTAMPS=(
# Weekly
# Daily (30)
"2021-01-01_00-00-00"
"2021-01-02_00-00-00"
"2021-01-03_00-00-00"
# Hourly (24)
"2021-01-03_12-00-00"
"2021-01-03_13-00-00"
"2021-01-03_14-00-00"
"2021-01-03_15-00-00"
"2021-01-03_16-00-00"
"2021-01-03_17-00-00"
"2021-01-03_18-00-00"
"2021-01-03_19-00-00"
"2021-01-03_20-00-00"
"2021-01-04_09-00-00"
"2021-01-04_10-00-00"
"2021-01-04_11-00-00"
# Sub-hourly (16)
"2021-01-04_12-00-00"
"2021-01-04_13-00-00"
"2021-01-04_14-00-00"
"2021-01-04_15-00-00"
"2021-01-04_16-00-00"
"2021-01-04_17-00-00"
"2021-01-04_18-00-00"
"2021-01-04_19-00-00"
"2021-01-04_20-00-00"
"2021-01-04_21-00-00"
"2021-01-04_22-00-00"
"2021-01-04_23-00-00"
"2021-01-05_00-00-00"
"2021-01-05_01-00-00"
"2021-01-05_02-00-00"
"2021-01-05_03-00-00"
)
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
check-backup "$TIMESTAMP.tar.gz"
done
}
test-restic-thinning-delete () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -m 70 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
EXPECTED_TIMESTAMPS=(
# Weekly
# Daily (30)
"2021-01-01 23:00:00"
"2021-01-02 23:00:00"
"2021-01-03 23:00:00"
# Hourly (24)
"2021-01-04 04:00:00"
"2021-01-04 05:00:00"
"2021-01-04 06:00:00"
"2021-01-04 07:00:00"
"2021-01-04 08:00:00"
"2021-01-04 09:00:00"
"2021-01-04 10:00:00"
"2021-01-04 11:00:00"
# Sub-hourly (16)
"2021-01-04 12:00:00"
"2021-01-04 13:00:00"
"2021-01-04 14:00:00"
"2021-01-04 15:00:00"
"2021-01-04 16:00:00"
"2021-01-04 17:00:00"
"2021-01-04 18:00:00"
"2021-01-04 19:00:00"
"2021-01-04 20:00:00"
"2021-01-04 21:00:00"
"2021-01-04 22:00:00"
"2021-01-04 23:00:00"
"2021-01-05 00:00:00"
"2021-01-05 01:00:00"
"2021-01-05 02:00:00"
"2021-01-05 03:00:00"
)
SNAPSHOTS="$(restic snapshots -r "$TEST_TMP/backups-restic")"
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
assertContains "$SNAPSHOTS" "$TIMESTAMP"
done
UNEXPECTED_TIMESTAMPS=(
"2021-01-01 00:00:00"
"2021-01-01 01:00:00"
"2021-01-01 02:00:00"
"2021-01-02 22:00:00"
"2021-01-03 22:00:00"
"2021-01-04 00:00:00"
)
for TIMESTAMP in "${UNEXPECTED_TIMESTAMPS[@]}"; do
assertNotContains "$SNAPSHOTS" "$TIMESTAMP"
done
}
test-thinning-delete-long () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i day")"
OUTPUT="$(./backup.sh -v -m 73 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP")"
done
UNEXPECTED_TIMESTAMPS=(
"2021-01-04_00-00-00"
"2021-01-05_00-00-00"
"2021-01-12_00-00-00"
"2021-01-24_00-00-00"
)
for TIMESTAMP in "${UNEXPECTED_TIMESTAMPS[@]}"; do
assertFalse '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
done
assertEquals 73 "$(find "$TEST_TMP/backups" -type f | wc -l)"
EXPECTED_TIMESTAMPS=(
# Weekly
"2021-01-11_00-00-00"
"2021-01-25_00-00-00"
"2021-01-25_00-00-00"
# Daily (30)
"2021-01-31_00-00-00"
"2021-03-01_00-00-00"
# Hourly (24)
"2021-03-02_00-00-00"
"2021-03-25_00-00-00"
# Sub-hourly (16)
"2021-03-26_00-00-00"
"2021-04-10_00-00-00"
)
assertContains "$OUTPUT" "promoted to next block"
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
check-backup "$TIMESTAMP.tar.gz"
done
}
# shellcheck disable=SC1091
. test/shunit2/shunit2