diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d76765e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto + +*.sh text eol=lf diff --git a/.travis.yml b/.travis.yml index e231c5f..9dc219f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,23 +5,15 @@ jobs: include: - stage: Linting and sanity tests name: Lint script with shellcheck - script: - - "readarray -t shell_scripts < <(find * -name '*.sh' -a ! -path '*/.git/*')" - - echo checking "${shell_scripts[@]}" - - shellcheck "${shell_scripts[@]}" + script: ./tests/lint.shellcheck.sh - name: Lint dockerfile - # DL3006 Always tag the version of an image explicitly - # DL3018 Pin versions in apk add. Instead of `apk add ` use `apk add =` - script: >- - docker run --rm -i hadolint/hadolint hadolint - --ignore DL3018 - --ignore DL3006 - - < Dockerfile - - name: Test simple backup and exclusion scenario + script: bash ./tests/lint.hadolint.sh + - name: Test simple backup and exclusion scenario with tar before_install: - sudo apt-get update - sudo apt-get install -y tree + coreutils env: SRC_DIR: /tmp/source DEST_DIR: /tmp/dest @@ -31,30 +23,20 @@ jobs: EXCLUDES: '*.jar,exclude_dir' RCON_PATH: /usr/bin/rcon-cli PRUNE_BACKUPS_DAYS: 3 - script: - - mkdir -p "${SRC_DIR}/"{in,ex}clude_dir "${DEST_DIR}" "${EXTRACT_DIR}" - - touch "${SRC_DIR}/"{backup_me.{1,2}.json,exclude_me.jar} - - touch "${SRC_DIR}/include_dir/"{backup_me.{1,2}.json,exclude_me.jar} - - touch "${SRC_DIR}/exclude_dir/"exclude_me.{1,2}.{json,jar} - - tree "${SRC_DIR}" - - touch -d "$(( PRUNE_BACKUPS_DAYS + 2 )) days ago" "${DEST_DIR}/fake_backup_that_should_be_deleted.tgz" - - ls -al "${DEST_DIR}" - - docker build -t testimg . - - echo -e '#!/bin/bash\ntrue' > rcon-cli && chmod +x rcon-cli - - timeout 50 docker run --rm - --env SRC_DIR - --env DEST_DIR - --env BACKUP_INTERVAL - --env INITIAL_DELAY - --env EXCLUDES - --env PRUNE_BACKUPS_DAYS - --mount "type=bind,src=${SRC_DIR},dst=${SRC_DIR}" - --mount "type=bind,src=${DEST_DIR},dst=${DEST_DIR}" - --mount "type=bind,src=$(pwd)/rcon-cli,dst=${RCON_PATH}" - testimg - - tree "${DEST_DIR}" - - tar -xzf "${DEST_DIR}/"*.tgz -C "${EXTRACT_DIR}" - - tree "${EXTRACT_DIR}" - - '[ -z "$(find "${EXTRACT_DIR}" -name "exclude_*" -print -quit)" ]' - - '[ 4 -eq "$(find "${EXTRACT_DIR}" -name "backup_me*" -print | wc -l)" ]' - + script: bash ./tests/test.simple.tar.sh + - name: Test restic handling + before_install: + - sudo apt-get update + - sudo apt-get install -y + tree + coreutils + env: + SRC_DIR: /tmp/source + DEST_DIR: /tmp/dest + RESTIC_REPOSITORY: /tmp/dest + BACKUP_INTERVAL: 0 + INITIAL_DELAY: 0s + EXCLUDES: '*.jar,exclude_dir' + RCON_PATH: /usr/bin/rcon-cli + RESTIC_PASSWORD: 1234 + script: bash ./tests/test.simple.restic.sh diff --git a/Dockerfile b/Dockerfile index d0e1fe9..69385b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,56 @@ -FROM alpine AS builder - -ARG RCON_CLI_VERSION=1.4.4 - -ADD https://github.com/itzg/rcon-cli/releases/download/${RCON_CLI_VERSION}/rcon-cli_${RCON_CLI_VERSION}_linux_amd64.tar.gz /tmp/rcon-cli.tgz - -RUN mkdir -p /opt/rcon-cli && \ - tar x -f /tmp/rcon-cli.tgz -C /opt/rcon-cli && \ - rm /tmp/rcon-cli.tgz - - - -FROM alpine - -RUN apk -U --no-cache add \ - bash \ - coreutils \ - unzip - -COPY --from=builder /opt/rcon-cli/rcon-cli /opt/rcon-cli/rcon-cli - -RUN ln -s /opt/rcon-cli/rcon-cli /usr/bin - -ENTRYPOINT ["/opt/backup-loop.sh"] - -VOLUME ["/data", "/backups"] - -COPY backup-loop.sh /opt/ - +FROM alpine AS builder + +ARG IMAGE_ARCH=amd64 + +ARG RCON_CLI_VERSION=1.4.4 + +ADD https://github.com/itzg/rcon-cli/releases/download/${RCON_CLI_VERSION}/rcon-cli_${RCON_CLI_VERSION}_linux_${IMAGE_ARCH}.tar.gz /tmp/rcon-cli.tar.gz + +RUN mkdir -p /opt/rcon-cli && \ + tar x -f /tmp/rcon-cli.tar.gz -C /opt/rcon-cli && \ + rm /tmp/rcon-cli.tar.gz + +ARG RESTIC_VERSION=0.9.5 + +ADD https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_${IMAGE_ARCH}.bz2 /tmp/restic.bz2 + +RUN bunzip2 /tmp/restic.bz2 && \ + mv /tmp/restic /opt/restic + +ARG DEMOTER_VERSION=0.1.0 + +ADD https://github.com/itzg/entrypoint-demoter/releases/download/${DEMOTER_VERSION}/entrypoint-demoter_${DEMOTER_VERSION}_linux_${IMAGE_ARCH}.tar.gz /tmp/entrypoint-demoter.tar.gz + +RUN mkdir -p /opt/entrypoint-demoter && \ + tar x -f /tmp/entrypoint-demoter.tar.gz -C /opt/entrypoint-demoter && \ + rm /tmp/entrypoint-demoter.tar.gz + + + +FROM alpine + +RUN apk -U --no-cache add \ + bash \ + coreutils + +COPY --from=builder /opt/rcon-cli/rcon-cli /opt/rcon-cli/rcon-cli + +RUN ln -s /opt/rcon-cli/rcon-cli /usr/bin + +COPY --from=builder /opt/restic /opt/restic + +RUN chmod +x /opt/restic && \ + ln -s /opt/restic /usr/bin + +COPY --from=builder /opt/entrypoint-demoter/entrypoint-demoter /opt/entrypoint-demoter + +RUN chmod +x /opt/entrypoint-demoter && \ + ln -s /opt/entrypoint-demoter /usr/bin + +COPY backup-loop.sh /opt/ + +VOLUME ["/data", "/backups"] + +ENTRYPOINT ["/opt/entrypoint-demoter", "--match", "/backups"] + +CMD ["/opt/backup-loop.sh"] diff --git a/LICENSE.txt b/LICENSE.txt index a804589..1f7872c 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2019 Geoff Bourne - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +MIT License + +Copyright (c) 2019 Geoff Bourne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 90136c5..1e84859 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,90 @@ -[![Docker Pulls](https://img.shields.io/docker/pulls/itzg/mc-backup.svg)](https://hub.docker.com/r/itzg/mc-backup) -[![Build Status](https://travis-ci.org/itzg/docker-mc-backup.svg?branch=master)](https://travis-ci.org/itzg/docker-mc-backup) - -Provides a side-car container to backup itzg/minecraft-server world data. - -## Environment variables - -- `SRC_DIR`=/data -- `DEST_DIR`=/backups -- `BACKUP_NAME`=world -- `INITIAL_DELAY`=2m -- `BACKUP_INTERVAL`=24h -- `PRUNE_BACKUPS_DAYS`=7 -- `RCON_PORT`=25575 -- `RCON_PASSWORD`=minecraft -- `EXCLUDES`=\*.jar,cache,logs -- `LINK_LATEST`=false - -If `PRUNE_BACKUP_DAYS` is set to a positive number, it'll delete old `.tgz` backup files from `DEST_DIR`. By default deletes backups older than a week. - -If `BACKUP_INTERVAL` is set to 0 or smaller, script will run once and exit. - -Both `INITIAL_DELAY` and `BACKUP_INTERVAL` accept times in `sleep` format: `NUMBER[SUFFIX] NUMBER[SUFFIX] ...`. -SUFFIX may be 's' for seconds (the default), 'm' for minutes, 'h' for hours or 'd' for days. - -Examples: -- `BACKUP_INTERVAL`="1.5d" -> backup every one and a half days (36 hours) -- `BACKUP_INTERVAL`="2h 30m" -> backup every two and a half hours -- `INITIAL_DELAY`="120" -> wait 2 minutes before starting - -`EXCLUDES` is a comma-separated list of glob(3) patterns to exclude from backups. By default excludes all jar files (plugins, server files), logs folder and cache (used by i.e. PaperMC server). - -`LINK_LATEST` is a true/false flag that creates a symbolic link to the latest backup. - -## Volumes - -- `/data` : - Should be attached read-only to the same volume as the `/data` of the `itzg/minecraft-server` container -- `/backups` : - The volume where incremental tgz files will be created. - -## Example - -An example StatefulSet deployment is provided [in this repository](test-deploy.yaml). - -The important part is the containers definition of the deployment: - -```yaml -containers: - - name: mc - image: itzg/minecraft-server - env: - - name: EULA - value: "TRUE" - volumeMounts: - - mountPath: /data - name: data - - name: backup - image: mc-backup - imagePullPolicy: Never - securityContext: - runAsUser: 1000 - env: - - name: BACKUP_INTERVAL - value: "2h 30m" - volumeMounts: - - mountPath: /data - name: data - readOnly: true - - mountPath: /backups - name: backups -``` +[![Docker Pulls](https://img.shields.io/docker/pulls/itzg/mc-backup.svg)](https://hub.docker.com/r/itzg/mc-backup) +[![Build Status](https://travis-ci.org/itzg/docker-mc-backup.svg?branch=master)](https://travis-ci.org/itzg/docker-mc-backup) + +Provides a side-car container to backup itzg/minecraft-server world data. + +## Environment variables + +##### Common variables: + +- `SRC_DIR`=/data +- `BACKUP_NAME`=world +- `INITIAL_DELAY`=2m +- `BACKUP_INTERVAL`=24h +- `PRUNE_BACKUPS_DAYS`=7 +- `RCON_PORT`=25575 +- `RCON_PASSWORD`=minecraft +- `EXCLUDES`=\*.jar,cache,logs +- `BACKUP_METHOD`=tar + +If `PRUNE_BACKUP_DAYS` is set to a positive number, it'll delete old `.tgz` backup files from `DEST_DIR`. By default deletes backups older than a week. + +If `BACKUP_INTERVAL` is set to 0 or smaller, script will run once and exit. + +Both `INITIAL_DELAY` and `BACKUP_INTERVAL` accept times in `sleep` format: `NUMBER[SUFFIX] NUMBER[SUFFIX] ...`. +SUFFIX may be 's' for seconds (the default), 'm' for minutes, 'h' for hours or 'd' for days. + +Examples: +- `BACKUP_INTERVAL`="1.5d" -> backup every one and a half days (36 hours) +- `BACKUP_INTERVAL`="2h 30m" -> backup every two and a half hours +- `INITIAL_DELAY`="120" -> wait 2 minutes before starting + +`EXCLUDES` is a comma-separated list of glob(3) patterns to exclude from backups. By default excludes all jar files (plugins, server files), logs folder and cache (used by i.e. PaperMC server). + +##### `tar` backup method + +- `DEST_DIR`=/backups +- `LINK_LATEST`=false + +`LINK_LATEST` is a true/false flag that creates a symbolic link to the latest backup. + +##### `restic` backup method + +See [restic documentation](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html) on what variables are needed to be defined. +At least one of `RESTIC_PASSWORD*` variables need to be defined, along with `RESTIC_REPOSITORY`. + + +:warning: | When using restic as your backup method, make sure that you fix your container hostname to a constant value! Otherwise, each time a container restarts it'll use a different, random hostname which will cause it not to rotate your backups created by previous instances! +---|--- + +:warning: | When using restic, at least one of `HOSTNAME` or `BACKUP_NAME` must be unique, when sharing a repository. Otherwise other instances using the same repository might prune your backups prematurely. +---|--- + +## Volumes + +- `/data` : + Should be attached read-only to the same volume as the `/data` of the `itzg/minecraft-server` container +- `/backups` : + The volume where incremental tgz files will be created, if using tar backup method. + +## Example + +An example StatefulSet deployment is provided [in this repository](test-deploy.yaml). + +The important part is the containers definition of the deployment: + +```yaml +containers: + - name: mc + image: itzg/minecraft-server + env: + - name: EULA + value: "TRUE" + volumeMounts: + - mountPath: /data + name: data + - name: backup + image: mc-backup + imagePullPolicy: Never + securityContext: + runAsUser: 1000 + env: + - name: BACKUP_INTERVAL + value: "2h 30m" + volumeMounts: + - mountPath: /data + name: data + readOnly: true + - mountPath: /backups + name: backups +``` diff --git a/backup-loop.sh b/backup-loop.sh index c15d2c3..78d880a 100755 --- a/backup-loop.sh +++ b/backup-loop.sh @@ -2,12 +2,12 @@ set -euo pipefail -readonly backup_extension="tgz" : "${SRC_DIR:=/data}" : "${DEST_DIR:=/backups}" : "${BACKUP_NAME:=world}" : "${INITIAL_DELAY:=2m}" : "${BACKUP_INTERVAL:=${INTERVAL_SEC:-24h}}" +: "${BACKUP_METHOD:=tar}" # currently one of tar, restic : "${PRUNE_BACKUPS_DAYS:=7}" : "${RCON_PORT:=25575}" : "${RCON_PASSWORD:=minecraft}" @@ -17,13 +17,45 @@ readonly backup_extension="tgz" export RCON_PORT export RCON_PASSWORD +############### +## common ## +## functions ## +############### + +is_elem_in_array() { + # $1 = element + # All remaining arguments are array to search for the element in + if [ "$#" -lt 2 ]; then + log INTERNALERROR "Wrong number of arguments passed to is_elem_in_array function" + return 2 + fi + local element="${1}" + shift + local e + for e; do + if [ "${element}" == "${e}" ]; then + return 0 + fi + done + return 1 +} + log() { if [ "$#" -lt 1 ]; then - echo "Wrong number of arguments passed to log function" >&2 - return 1 + log INTERNALERROR "Wrong number of arguments passed to log function" + return 2 fi local level="${1}" shift + local valid_levels=( + "INFO" + "ERROR" + "INTERNALERROR" + ) + if ! is_elem_in_array "${level}" "${valid_levels[@]}"; then + log INTERNALERROR "Log level ${level} is not a valid level." + return 2 + fi ( # If any arguments are passed besides log level if [ "$#" -ge 1 ]; then @@ -33,16 +65,15 @@ log() { # otherwise read log messages from standard input cat - fi + if [ "${level}" == "INTERNALERROR" ]; then + echo "Please report this: https://github.com/itzg/docker-mc-backup/issues" + fi ) | awk -v level="${level}" '{ printf("%s %s %s\n", strftime("%FT%T%z"), level, $0); fflush(); }' -} - -find_old_backups() { - find "${DEST_DIR}" -maxdepth 1 -name "*.${backup_extension}" -mtime "+${PRUNE_BACKUPS_DAYS}" "${@}" -} +} >&2 retry() { if [ "$#" -lt 3 ]; then - log ERROR "Wrong number of arguments passed to retry function" + log INTERNALERROR "Wrong number of arguments passed to retry function" return 1 fi @@ -78,10 +109,153 @@ retry() { return 2 } +is_function() { + if [ "${#}" -ne 1 ]; then + log INTERNALERROR "is_function expects 1 argument, received ${#}" + fi + name="${1}" + [ "$(type -t "${name}")" == "function" ] +} + +call_if_function_exists() { + if [ "${#}" -lt 1 ]; then + log INTERNALERROR "call_if_function_exists expects at least 1 argument, received ${#}" + return 2 + fi + function_name="${1}" + if is_function "${function_name}"; then + eval "${@}" + else + log INTERNALERROR "${function_name} is not a valid function!" + return 2 + fi +} + +##################### +## specific method ## +## functions ## +##################### +# Each function that corresponds to a name of a backup method +# Should define following functions inside them +# init() -> called before entering loop. Verify arguments, prepare for operations etc. +# backup() -> create backup. It's guaranteed that all data is already flushed to disk. +# prune() -> prune old backups. PRUNE_BACKUPS_DAYS is guaranteed to be positive. + + +tar() { + _find_old_backups() { + find "${DEST_DIR}" -maxdepth 1 -name "*.${backup_extension}" -mtime "+${PRUNE_BACKUPS_DAYS}" "${@}" + } + + init() { + mkdir -p "${DEST_DIR}" + readonly backup_extension="tgz" + } + backup() { + ts=$(date -u +"%Y%m%d-%H%M%S") + outFile="${DEST_DIR}/${BACKUP_NAME}-${ts}.${backup_extension}" + log INFO "Backing up content in ${SRC_DIR} to ${outFile}" + command tar "${excludes[@]}" -czf "${outFile}" -C "${SRC_DIR}" . + if [ "${LINK_LATEST^^}" == "TRUE" ]; then + ln -sf "${BACKUP_NAME}-${ts}.${backup_extension}" "${DEST_DIR}/latest.${backup_extension}" + fi + } + prune() { + if [ -n "$(_find_old_backups -print -quit)" ]; then + log INFO "Pruning backup files older than ${PRUNE_BACKUPS_DAYS} days" + _find_old_backups -print -delete | awk '{ printf "Removing %s\n", $0 }' | log INFO + fi + } + call_if_function_exists "${@}" +} + + +restic() { + _delete_old_backups() { + command restic forget --tag "${restic_tags_filter}" --keep-within "${PRUNE_BACKUPS_DAYS}d" "${@}" + } + _check() { + if ! output="$(command restic check 2>&1)"; then + log ERROR "Repository contains error! Aborting" + <<<"${output}" log ERROR + return 1 + fi + } + init() { + if [ -z "${RESTIC_PASSWORD:-}" ] \ + && [ -z "${RESTIC_PASSWORD_FILE:-}" ] \ + && [ -z "${RESTIC_PASSWORD_COMMAND:-}" ]; then + log ERROR "At least one of" RESTIC_PASSWORD{,_FILE,_COMMAND} "needs to be set!" + return 1 + fi + if [ -z "${RESTIC_REPOSITORY:-}" ]; then + log ERROR "RESTIC_REPOSITORY is not set!" + return 1 + fi + # TODO: Not sure if this method is enough to determine if all needed configs are provided. + if output="$(command restic snapshots 2>&1 >/dev/null)"; then + log INFO "Repository already initialized" + _check + elif <<<"${output}" grep -q 'no such file or directory$'; then + log INFO "Initializing new restic repository..." + command restic init | log INFO + elif <<<"${output}" grep -q 'wrong password'; then + <<<"${output}" log ERROR + log ERROR "Wrong password provided to an existing repository?" + return 1 + else + log INTERNALERROR "Unhandled restic repository state." + return 2 + fi + + # Used to construct tagging arguments and filters for snapshots + readonly restic_tags=( + "mc_backups" + "${BACKUP_NAME}" + ) + # Arguments to use to tag the snapshots with + restic_tags_arguments=() + local tag + for tag in "${restic_tags[@]}"; do + restic_tags_arguments+=( --tag "${tag}" ) + done + readonly restic_tags_arguments + # Used for filtering backups to only match ours + restic_tags_filter="$(IFS=,; echo "${restic_tags[*]}")" + readonly restic_tags_filter + } + backup() { + log INFO "Backing up content in ${SRC_DIR}" + command restic backup "${restic_tags_arguments[@]}" "${excludes[@]}" "${SRC_DIR}" | log INFO + } + prune() { + # We cannot use `grep -q` here - see https://github.com/restic/restic/issues/1466 + if _delete_old_backups --dry-run | grep '^remove [[:digit:]]* snapshots:$' >/dev/null; then + log INFO "Forgetting snapshots older than ${PRUNE_BACKUPS_DAYS} days" + _delete_old_backups --prune | log INFO + _check | log INFO + fi + } + call_if_function_exists "${@}" +} + +########## +## main ## +########## + if [ -n "${INTERVAL_SEC:-}" ]; then log WARN 'INTERVAL_SEC is deprecated. Use BACKUP_INTERVAL instead' fi +if [ ! -d "${SRC_DIR}" ]; then + log ERROR 'SRC_DIR does not point to an existing directory!' + exit 1 +fi + +if ! is_function "${BACKUP_METHOD}"; then + log ERROR "Invalid BACKUP_METHOD provided: ${BACKUP_METHOD}" +fi + # We unfortunately can't use a here-string, as it inserts new line at the end readarray -td, excludes_patterns < <(printf '%s' "${EXCLUDES}") @@ -90,6 +264,7 @@ for pattern in "${excludes_patterns[@]}"; do excludes+=(--exclude "${pattern}") done +"${BACKUP_METHOD}" init log INFO "waiting initial delay of ${INITIAL_DELAY}..." # shellcheck disable=SC2086 @@ -101,7 +276,6 @@ retry 20 10s rcon-cli save-on while true; do - ts=$(date -u +"%Y%m%d-%H%M%S") if retry 5 10s rcon-cli save-off; then # No matter what we were doing, from now on if the script crashes @@ -109,18 +283,9 @@ while true; do trap 'retry 5 5s rcon-cli save-on' EXIT retry 5 10s rcon-cli save-all - outFile="${DEST_DIR}/${BACKUP_NAME}-${ts}.${backup_extension}" - log INFO "backing up content in ${SRC_DIR} to ${outFile}" + retry 5 10s sync - # shellcheck disable=SC2086 - if tar "${excludes[@]}" -czf "${outFile}" -C "${SRC_DIR}" .; then - log INFO "successfully backed up" - if [ "${LINK_LATEST^^}" == "TRUE" ]; then - ln -sf "${BACKUP_NAME}-${ts}.${backup_extension}" "${DEST_DIR}/latest.${backup_extension}" - fi - else - log ERROR "backup failed" - fi + "${BACKUP_METHOD}" backup retry 20 10s rcon-cli save-on # Remove our exit trap now @@ -130,9 +295,8 @@ while true; do exit 1 fi - if (( PRUNE_BACKUPS_DAYS > 0 )) && [ -n "$(find_old_backups -print -quit)" ]; then - log INFO "pruning backup files older than ${PRUNE_BACKUPS_DAYS} days" - find_old_backups -print -delete | log INFO + if (( PRUNE_BACKUPS_DAYS > 0 )); then + "${BACKUP_METHOD}" prune fi # If BACKUP_INTERVAL is not a valid number (i.e. 24h), we want to sleep. diff --git a/test-deploy.yaml b/test-deploy.yaml index 57bf9b4..df66365 100644 --- a/test-deploy.yaml +++ b/test-deploy.yaml @@ -1,78 +1,78 @@ ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - labels: - app: test-mc-backup - name: test-mc-backup -spec: - replicas: 1 - serviceName: test-mc-backup - selector: - matchLabels: - app: test-mc-backup - template: - metadata: - labels: - app: test-mc-backup - spec: - containers: - - name: mc - image: itzg/minecraft-server - env: - - name: EULA - value: "TRUE" - volumeMounts: - - mountPath: /data - name: data - - name: backup - image: mc-backup - imagePullPolicy: Never - securityContext: - runAsUser: 1000 - env: - - name: BACKUP_INTERVAL - value: "60" - - name: INITIAL_DELAY - value: "10" - volumeMounts: - - mountPath: /data - name: data - readOnly: true - - mountPath: /backups - name: backups - volumes: - - name: data - persistentVolumeClaim: - claimName: data - - name: backups - persistentVolumeClaim: - claimName: backups - volumeClaimTemplates: - - metadata: - name: data - spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 100Mi - - metadata: - name: backups - spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 500Mi ---- -apiVersion: v1 -kind: Service -metadata: - name: test-mc-backup -spec: - selector: - app: test-mc-backup - ports: - - port: 25565 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + labels: + app: test-mc-backup + name: test-mc-backup +spec: + replicas: 1 + serviceName: test-mc-backup + selector: + matchLabels: + app: test-mc-backup + template: + metadata: + labels: + app: test-mc-backup + spec: + containers: + - name: mc + image: itzg/minecraft-server + env: + - name: EULA + value: "TRUE" + volumeMounts: + - mountPath: /data + name: data + - name: backup + image: mc-backup + imagePullPolicy: Never + securityContext: + runAsUser: 1000 + env: + - name: BACKUP_INTERVAL + value: "60" + - name: INITIAL_DELAY + value: "10" + volumeMounts: + - mountPath: /data + name: data + readOnly: true + - mountPath: /backups + name: backups + volumes: + - name: data + persistentVolumeClaim: + claimName: data + - name: backups + persistentVolumeClaim: + claimName: backups + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi + - metadata: + name: backups + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 500Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: test-mc-backup +spec: + selector: + app: test-mc-backup + ports: + - port: 25565 type: NodePort \ No newline at end of file diff --git a/tests/common.bootstrap.sh b/tests/common.bootstrap.sh new file mode 100755 index 0000000..da69761 --- /dev/null +++ b/tests/common.bootstrap.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -euo pipefail + + +mkdir -p "${LOCAL_SRC_DIR}/"{in,ex}clude_dir "${LOCAL_DEST_DIR}" +touch "${LOCAL_SRC_DIR}/"{backup_me.{1,2}.json,exclude_me.jar} +touch "${LOCAL_SRC_DIR}/include_dir/"{backup_me.{1,2}.json,exclude_me.jar} +touch "${LOCAL_SRC_DIR}/exclude_dir/"exclude_me.{1,2}.{json,jar} +tree "${LOCAL_SRC_DIR}" +docker build -t testimg . +echo -e '#!/bin/bash\ntrue' > "${TMP_DIR}/rcon-cli" && chmod +x "${TMP_DIR}/rcon-cli" diff --git a/tests/lint.hadolint.sh b/tests/lint.hadolint.sh new file mode 100755 index 0000000..d75ba9f --- /dev/null +++ b/tests/lint.hadolint.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKDIR="$(readlink -m "$(dirname "${0}")")" + +cd "${WORKDIR}/.." + +# DL3006 Always tag the version of an image explicitly +# DL3018 Pin versions in apk add. Instead of `apk add ` use `apk add =` +docker run --rm -i hadolint/hadolint hadolint \ + --ignore DL3018 \ + --ignore DL3006 \ + - < Dockerfile diff --git a/tests/lint.shellcheck.sh b/tests/lint.shellcheck.sh new file mode 100755 index 0000000..3e3e737 --- /dev/null +++ b/tests/lint.shellcheck.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKDIR="$(readlink -m "$(dirname "${0}")")" + +cd "${WORKDIR}/.." + +readarray -t shell_scripts < <(find . -name '*.sh' -a ! -path '*/.git/*') +echo checking "${shell_scripts[@]}" +shellcheck "${shell_scripts[@]}" diff --git a/tests/test.simple.restic.sh b/tests/test.simple.restic.sh new file mode 100755 index 0000000..67d31c1 --- /dev/null +++ b/tests/test.simple.restic.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +set -x + +WORKDIR="$(readlink -m "$(dirname "${0}")")" + +cd "${WORKDIR}/.." + +TMP_DIR="$(mktemp --directory)" +trap 'sudo rm -rf "${TMP_DIR}"' EXIT + +: "${SRC_DIR:=/tmp/source}" +: "${DEST_DIR:=/tmp/dest}" +: "${RESTIC_REPOSITORY:=/tmp/dest}" +: "${BACKUP_INTERVAL:=0}" +: "${INITIAL_DELAY:=0s}" +: "${EXCLUDES:='*.jar,exclude_dir'}" +: "${RCON_PATH:=/usr/bin/rcon-cli}" +: "${RESTIC_PASSWORD:=1234}" + +export LOCAL_SRC_DIR="${TMP_DIR}/${SRC_DIR}" +export LOCAL_DEST_DIR="${TMP_DIR}/${DEST_DIR}" +export TMP_DIR + +"${WORKDIR}/common.bootstrap.sh" + +export SRC_DIR +export DEST_DIR +export RESTIC_REPOSITORY +export BACKUP_INTERVAL +export INITIAL_DELAY +export EXCLUDES +export RCON_PATH +export RESTIC_PASSWORD + +timeout --kill-after=20 50 docker run --rm \ + --env SRC_DIR \ + --env BACKUP_INTERVAL \ + --env INITIAL_DELAY \ + --env EXCLUDES \ + --env PRUNE_BACKUPS_DAYS \ + --env RESTIC_REPOSITORY \ + --env RESTIC_PASSWORD \ + --env BACKUP_METHOD=restic \ + --mount "type=bind,src=${LOCAL_SRC_DIR},dst=${SRC_DIR}" \ + --mount "type=bind,src=${LOCAL_DEST_DIR},dst=${DEST_DIR}" \ + --mount "type=bind,src=${TMP_DIR}/rcon-cli,dst=${RCON_PATH}" \ + testimg + +restic() { + docker run --rm \ + --env RESTIC_REPOSITORY \ + --env RESTIC_PASSWORD \ + --entrypoint=restic \ + --mount "type=bind,src=${LOCAL_SRC_DIR},dst=${SRC_DIR}" \ + --mount "type=bind,src=${LOCAL_DEST_DIR},dst=${DEST_DIR}" \ + testimg "${@}" +} +restic ls latest +! restic ls latest 2>/dev/null | grep -q "exclude_" +[ 4 -eq "$(restic ls latest 2>/dev/null | grep -c "backup_me")" ] diff --git a/tests/test.simple.tar.sh b/tests/test.simple.tar.sh new file mode 100755 index 0000000..7871a1c --- /dev/null +++ b/tests/test.simple.tar.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +set -x + +WORKDIR="$(readlink -m "$(dirname "${0}")")" + +cd "${WORKDIR}/.." + +TMP_DIR="$(mktemp --directory)" +trap 'sudo rm -rf "${TMP_DIR}"' EXIT + +: "${SRC_DIR:=/tmp/source}" +: "${DEST_DIR:=/tmp/dest}" +: "${BACKUP_INTERVAL:=0}" +: "${INITIAL_DELAY:=5s}" +: "${EXCLUDES:='*.jar,exclude_dir'}" +: "${RCON_PATH:=/usr/bin/rcon-cli}" +: "${PRUNE_BACKUPS_DAYS:=3}" + +export LOCAL_SRC_DIR="${TMP_DIR}/${SRC_DIR}" +export LOCAL_DEST_DIR="${TMP_DIR}/${DEST_DIR}" +export TMP_DIR + +"${WORKDIR}/common.bootstrap.sh" + +export SRC_DIR +export DEST_DIR +export EXTRACT_DIR="${TMP_DIR}/extract" +export BACKUP_INTERVAL +export INITIAL_DELAY +export EXCLUDES +export RCON_PATH +export PRUNE_BACKUPS_DAYS + +mkdir "${EXTRACT_DIR}" +touch -d "$(( PRUNE_BACKUPS_DAYS + 2 )) days ago" "${LOCAL_DEST_DIR}/fake_backup_that_should_be_deleted.tgz" +ls -al "${LOCAL_DEST_DIR}" + +timeout 50 docker run --rm \ + --env SRC_DIR \ + --env DEST_DIR \ + --env BACKUP_INTERVAL \ + --env INITIAL_DELAY \ + --env EXCLUDES \ + --env PRUNE_BACKUPS_DAYS \ + --mount "type=bind,src=${LOCAL_SRC_DIR},dst=${SRC_DIR}" \ + --mount "type=bind,src=${LOCAL_DEST_DIR},dst=${DEST_DIR}" \ + --mount "type=bind,src=${TMP_DIR}/rcon-cli,dst=${RCON_PATH}" \ + testimg + +tree "${LOCAL_DEST_DIR}" +tar -xzf "${LOCAL_DEST_DIR}/"*.tgz -C "${EXTRACT_DIR}" +tree "${EXTRACT_DIR}" +[ -z "$(find "${EXTRACT_DIR}" -name "exclude_*" -print -quit)" ] +[ 4 -eq "$(find "${EXTRACT_DIR}" -name "backup_me*" -print | wc -l)" ] +