Compare commits
20 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
07510de420 | ||
![]() |
a569083736 | ||
![]() |
3afbdc712f | ||
![]() |
bbab6535a1 | ||
![]() |
9f24441733 | ||
![]() |
7f62b04d34 | ||
![]() |
2d439bf042 | ||
![]() |
5bf452994f | ||
![]() |
66a77abbca | ||
![]() |
27ab7e2087 | ||
![]() |
9605352be0 | ||
![]() |
4350eeb9ae | ||
![]() |
7e4772006f | ||
![]() |
94e894645f | ||
![]() |
8cf268f40b | ||
![]() |
2ec8f7f4dc | ||
![]() |
5eaae39e73 | ||
![]() |
02985989af | ||
![]() |
7d548ce28e | ||
![]() |
f005e54965 |
23 changed files with 789 additions and 1520 deletions
1
.envrc
1
.envrc
|
@ -1 +0,0 @@
|
|||
use flake
|
102
.github/workflows/ci.yml
vendored
102
.github/workflows/ci.yml
vendored
|
@ -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
2
.gitignore
vendored
|
@ -1,2 +0,0 @@
|
|||
coverage
|
||||
.direnv
|
3
.gitmodules
vendored
3
.gitmodules
vendored
|
@ -1,3 +0,0 @@
|
|||
[submodule "test/shunit2"]
|
||||
path = test/shunit2
|
||||
url = https://github.com/kward/shunit2.git
|
10
Dockerfile
10
Dockerfile
|
@ -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
221
README.md
|
@ -1,56 +1,48 @@
|
|||
# Minecraft Backup
|
||||
[](https://github.com/nicolaschan/minecraft-backup/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/nicolaschan/minecraft-backup)
|
||||
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).
|
||||
|
||||
Backup script for Minecraft servers on Linux.
|
||||
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.
|
||||
## Quick Start
|
||||
```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
|
||||
- Create backups of your world folder
|
||||
- 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
|
||||
- Works on vanilla (no plugins required)
|
||||
- Print backup status to the Minecraft chat
|
||||
- Choose your own compression algorithm (tested with: `gzip`, `xz`, `zstd`)
|
||||
- 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
|
||||
wget https://github.com/nicolaschan/minecraft-backup/releases/latest/download/backup.sh
|
||||
chmod +x backup.sh
|
||||
# Download the scripts
|
||||
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.
|
||||
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.
|
||||
|
||||
## Usage Options
|
||||
Command line options:
|
||||
```text
|
||||
-a Compression algorithm (default: gzip)
|
||||
|
@ -58,67 +50,158 @@ Command line options:
|
|||
-d Delete method: thin (default), sequential, none
|
||||
-e Compression file extension, exclude leading "." (default: gz)
|
||||
-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 Set hostname for restic backup (restic only)
|
||||
-i Input directory (path to world folder, use -i once for each world)
|
||||
-i Input directory (path to world folder)
|
||||
-l Compression level (default: 3)
|
||||
-m Maximum backups to keep, use -1 for unlimited (default: 128)
|
||||
-o Output directory
|
||||
-p Prefix that shows in Minecraft chat (default: Backup)
|
||||
-q Suppress warnings
|
||||
-r Restic repo name (if using restic)
|
||||
-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)
|
||||
-s Minecraft server screen name
|
||||
-v Verbose mode
|
||||
-w Window manager: screen (default), tmux, RCON
|
||||
```
|
||||
|
||||
### Automate backups with cron
|
||||
- Edit the crontab with `crontab -e`
|
||||
## Example Usage
|
||||
### 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:
|
||||
```
|
||||
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
|
||||
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:
|
||||
```bash
|
||||
mkdir restored-world
|
||||
cd restored-world
|
||||
tar -xzvf /path/to/backups/2019-04-09_02-15-01.tar.gz
|
||||
# if using gzip:
|
||||
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`
|
||||
Use [`restic restore`](https://restic.readthedocs.io/en/latest/050_restore.html) to restore from backup.
|
||||
## Using [restic](https://github.com/restic/restic)
|
||||
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?
|
||||
If you use `tar` while the server is running, you will likely get an error like this because Minecraft autosaves the world periodically:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
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.
|
||||
|
||||
See above for an example automating this using systemd timers.
|
||||
|
||||
## 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 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
|
||||
- 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"
|
||||
|
||||
## 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
|
||||
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:
|
||||
- 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.
|
||||
- Check that your backups work from time to time.
|
||||
|
||||
|
|
100
backup-restic.sh
Executable file
100
backup-restic.sh
Executable 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
546
backup.sh
|
@ -1,567 +1,121 @@
|
|||
#!/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, tmux, or RCON.
|
||||
# For Minecraft servers running in a GNU screen.
|
||||
# For most convenience, run automatically with cron.
|
||||
|
||||
# Default Configuration
|
||||
SCREEN_NAME="" # Name of the GNU Screen, tmux session, or hostname:port:password for RCON
|
||||
SERVER_WORLDS=() # Server world directory
|
||||
SCREEN_NAME="" # Name of the GNU Screen your Minecraft server is running in
|
||||
SERVER_WORLD="" # Server world directory
|
||||
BACKUP_DIRECTORY="" # Directory to save backups in
|
||||
MAX_BACKUPS=128 # -1 indicates unlimited
|
||||
DELETE_METHOD="thin" # Choices: thin, sequential, none; sequential: delete oldest; thin: keep last 24 hourly, last 30 daily, and monthly (use with 1 hr cron interval)
|
||||
COMPRESSION_ALGORITHM="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_LEVEL=3 # Passed to the compression algorithm
|
||||
ENABLE_CHAT_MESSAGES=false # Tell players in Minecraft chat about backup status
|
||||
PREFIX="Backup" # Shows in the chat message
|
||||
DEBUG=false # Enable debug messages
|
||||
SUPPRESS_WARNINGS=false # Suppress warnings
|
||||
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)
|
||||
DATE_FORMAT="%F_%H-%M-%S"
|
||||
TIMESTAMP=$(date +$DATE_FORMAT)
|
||||
|
||||
log-fatal () {
|
||||
echo -e "\033[0;31mFATAL:\033[0m $*"
|
||||
}
|
||||
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
|
||||
OPTIND=1
|
||||
while getopts 'a:bcd:e:f:ghi:l:m: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 ;;
|
||||
h) echo "Minecraft Backup"
|
||||
echo "Repository: https://github.com/nicolaschan/minecraft-backup"
|
||||
g) EXIT_IF_NO_SCREEN=true ;;
|
||||
h) echo "Minecraft Backup Script: https://github.com/nicolaschan/minecraft-backup.git"
|
||||
echo "-a Compression algorithm (default: gzip)"
|
||||
echo "-c Enable chat messages"
|
||||
echo "-d Delete method: thin (default), sequential, none"
|
||||
echo "-e Compression file extension, exclude leading \".\" (default: gz)"
|
||||
echo "-f Output file name (default is the timestamp)"
|
||||
echo "-g Do not backup (exit) if screen is not running (default: always backup)"
|
||||
echo "-h Shows this help text"
|
||||
echo "-H Set hostname for restic backup (restic only)"
|
||||
echo "-i Input directory (path to world folder, use -i once for each world)"
|
||||
echo "-i Input directory (path to world folder)"
|
||||
echo "-l Compression level (default: 3)"
|
||||
echo "-m Maximum backups to keep, use -1 for unlimited (default: 128)"
|
||||
echo "-o Output directory"
|
||||
echo "-p Prefix that shows in Minecraft chat (default: Backup)"
|
||||
echo "-q Suppress warnings"
|
||||
echo "-r Restic repo name (if using restic)"
|
||||
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 "-s Minecraft server screen name"
|
||||
echo "-v Verbose mode"
|
||||
echo "-w Window manager: screen (default), tmux, RCON"
|
||||
exit 0
|
||||
;;
|
||||
H) RESTIC_HOSTNAME=$OPTARG ;;
|
||||
i) SERVER_WORLDS+=("$OPTARG") ;;
|
||||
i) SERVER_WORLD=$OPTARG ;;
|
||||
l) COMPRESSION_LEVEL=$OPTARG ;;
|
||||
m) MAX_BACKUPS=$OPTARG ;;
|
||||
o) BACKUP_DIRECTORY=$OPTARG ;;
|
||||
p) PREFIX=$OPTARG ;;
|
||||
q) SUPPRESS_WARNINGS=true ;;
|
||||
r) RESTIC_REPO=$OPTARG ;;
|
||||
s) SCREEN_NAME=$OPTARG ;;
|
||||
t) LOCK_FILE=$OPTARG ;;
|
||||
u) LOCK_FILE_TIMEOUT=$OPTARG ;;
|
||||
v) DEBUG=true ;;
|
||||
w) WINDOW_MANAGER=$OPTARG ;;
|
||||
*) log-fatal "Invalid option -$FLAG"; exit 1 ;;
|
||||
*) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
rcon-command () {
|
||||
HOST="$(echo "$1" | cut -d: -f1)"
|
||||
PORT="$(echo "$1" | cut -d: -f2)"
|
||||
PASSWORD="$(echo "$1" | cut -d: -f3-)"
|
||||
COMMAND="$2"
|
||||
BASE_DIR=$(dirname "$(realpath "$0")")
|
||||
|
||||
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" == "-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=""
|
||||
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
|
||||
|
||||
if [[ "$COMPRESSION_FILE_EXTENSION" == "." ]]; then
|
||||
COMPRESSION_FILE_EXTENSION=""
|
||||
fi
|
||||
# shellcheck source=src/logging.sh
|
||||
source "$BASE_DIR/src/logging.sh" \
|
||||
-q "$SUPPRESS_WARNINGS" \
|
||||
-v "$DEBUG"
|
||||
|
||||
# Check for missing encouraged arguments
|
||||
if ! $SUPPRESS_WARNINGS; then
|
||||
if [[ "$SCREEN_NAME" == "" ]]; then
|
||||
log-warning "Minecraft screen/tmux/rcon location not specified (use -s)"
|
||||
fi
|
||||
if [[ $SCREEN_NAME == "" ]]; then
|
||||
log-warning "Minecraft screen name not specified (use -s)"
|
||||
fi
|
||||
|
||||
# Check for required arguments
|
||||
MISSING_CONFIGURATION=false
|
||||
if [[ "${#SERVER_WORLDS[@]}" == "0" ]]; then
|
||||
if [[ $SERVER_WORLD == "" ]]; then
|
||||
log-fatal "Server world not specified (use -i)"
|
||||
MISSING_CONFIGURATION=true
|
||||
fi
|
||||
if [[ "$BACKUP_DIRECTORY" == "" ]] && [[ "$RESTIC_REPO" == "" ]]; then
|
||||
log-fatal "Backup location not specified (use -o or -r)"
|
||||
if [[ $BACKUP_DIRECTORY == "" ]]; then
|
||||
log-fatal "Backup directory not specified (use -o)"
|
||||
MISSING_CONFIGURATION=true
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
|
||||
ARCHIVE_FILE_NAME="$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION"
|
||||
ARCHIVE_PATH="$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME"
|
||||
fi
|
||||
if [[ "$RESTIC_REPO" != "" ]]; then
|
||||
ARCHIVE_PATH="$RESTIC_REPO $TIMESTAMP"
|
||||
fi
|
||||
|
||||
# Minecraft server screen interface functions
|
||||
message-players () {
|
||||
local MESSAGE=$1
|
||||
local HOVER_MESSAGE=$2
|
||||
message-players-color "$MESSAGE" "$HOVER_MESSAGE" "gray"
|
||||
}
|
||||
execute-command () {
|
||||
local COMMAND=$1
|
||||
if [[ $SCREEN_NAME != "" ]]; then
|
||||
case $WINDOW_MANAGER in
|
||||
"screen") screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)"
|
||||
;;
|
||||
"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
|
||||
"$BASE_DIR/src/core.sh" \
|
||||
"$BASE_DIR/src/exec-methods/screen.sh" \
|
||||
-s "$SCREEN_NAME" \
|
||||
-- \
|
||||
"$BASE_DIR/src/backup-methods/tar.sh" \
|
||||
-a "$COMPRESSION_ALGORITHM" \
|
||||
-d "$DELETE_METHOD" \
|
||||
-e "$COMPRESSION_FILE_EXTENSION" \
|
||||
-f "$TIMESTAMP" \
|
||||
-i "$SERVER_WORLD" \
|
||||
-l "$COMPRESSION_LEVEL" \
|
||||
-m "$MAX_BACKUPS" \
|
||||
-o "$BACKUP_DIRECTORY" \
|
||||
-- \
|
||||
-c "$ENABLE_CHAT_MESSAGES" \
|
||||
-g "$EXIT_IF_NO_SCREEN" \
|
||||
-p "$PREFIX" \
|
||||
-q "$SUPPRESS_WARNINGS" \
|
||||
-v "$DEBUG"
|
||||
|
|
61
flake.lock
generated
61
flake.lock
generated
|
@ -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
|
||||
}
|
36
flake.nix
36
flake.nix
|
@ -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
119
rcon.sh
|
@ -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
27
src/backup-methods/restic.sh
Executable 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
188
src/backup-methods/tar.sh
Executable 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
181
src/core.sh
Executable 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
21
src/exec-methods/screen.sh
Executable file
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Execute commands on a Minecraft server running in a GNU screen
|
||||
|
||||
OPTIND=1
|
||||
while getopts 's:' FLAG "$@"; do
|
||||
case $FLAG in
|
||||
s) SCREEN_NAME=$OPTARG ;;
|
||||
*) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
minecraft-backup-execute () {
|
||||
local COMMAND=$1
|
||||
if ! screen -S "$SCREEN_NAME" -Q "select" .; then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$SCREEN_NAME" != "" ]]; then
|
||||
screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)"
|
||||
fi
|
||||
}
|
47
src/logging.sh
Normal file
47
src/logging.sh
Normal 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
23
test.sh
Executable 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
1
test/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
tmp
|
|
@ -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%"}]}}}]
|
|
@ -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%"}]}}}]
|
|
@ -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
|
578
test/test.sh
578
test/test.sh
|
@ -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
|
Loading…
Add table
Reference in a new issue