* refactor: Change INSTALL_PREFIX placeholder notation to templating notation
The former shell notation `$INSTALL_PREFIX` could result confusing. As
this is a placeholder that's replaced in the `make`, we switch it to a
template notation `{{ INSTALL_PREFIX }}`.
* docs: Small fix on README/manual setup
* docs(email-notif): Document the `{{ INSTALL_PREFIX }}` placeholder
co-authored-by: giuaig <13609224+giuaig@users.noreply.github.com>
* Update README.md
Co-authored-by: Gerard Bosch <30733556+gerardbosch@users.noreply.github.com>
---------
Co-authored-by: giuaig <13609224+giuaig@users.noreply.github.com>
Co-authored-by: Erik Westrup <erik.westrup@icloud.com>
184 lines
7.4 KiB
Bash
Executable File
184 lines
7.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Make a backup with restic to Backblaze B2.
|
|
#
|
|
# This script is typically run (as root user) either like:
|
|
# - from restic service/timer: $PREFIX/etc/systemd/system/restic-backup.{service,timer}
|
|
# - from a cronjob: $PREFIX/etc/cron.d/restic
|
|
# - manually by a user. For it to work, the environment variables must be set in the shell where this script is executed
|
|
# $ source $PREFIX/etc/default.env.sh
|
|
# $ restic_backup.sh
|
|
|
|
set -o errexit
|
|
set -o pipefail
|
|
[[ "${TRACE-0}" =~ ^1|t|y|true|yes$ ]] && set -o xtrace
|
|
|
|
# Clean up lock if we are killed.
|
|
# If killed by systemd, like $(systemctl stop restic), then it kills the whole cgroup and all it's subprocesses.
|
|
# However if we kill this script ourselves, we need this trap that kills all subprocesses manually.
|
|
exit_hook() {
|
|
echo "In exit_hook(), being killed" >&2
|
|
jobs -p | xargs kill
|
|
restic unlock
|
|
}
|
|
trap exit_hook INT TERM
|
|
|
|
|
|
# Assert that all needed environment variables are set.
|
|
# TODO in future if this grows, move this to a restic_lib.sh
|
|
assert_envvars() {
|
|
local varnames=("$@")
|
|
for varname in "${varnames[@]}"; do
|
|
if [ -z ${!varname+x} ]; then
|
|
printf "%s must be set for this script to work.\n\nDid you forget to source a {{ INSTALL_PREFIX }}/etc/restic/*.env.sh profile in the current shell before executing this script?\n" "$varname" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
}
|
|
|
|
warn_on_missing_envvars() {
|
|
local unset_envs=()
|
|
local varnames=("$@")
|
|
for varname in "${varnames[@]}"; do
|
|
if [ -z "${!varname-}" ]; then
|
|
unset_envs=("${unset_envs[@]}" "$varname")
|
|
fi
|
|
done
|
|
|
|
if [ ${#unset_envs[@]} -gt 0 ]; then
|
|
printf "The following env variables are recommended, but have not been set. This script may not work as expected: %s\n" "${unset_envs[*]}" >&2
|
|
fi
|
|
}
|
|
|
|
# Log the backup summary stats to a CSV file
|
|
logBackupStatsCsv() {
|
|
local snapId="$1" added="$2" removed="$3" snapSize="$4"
|
|
local logFile
|
|
logFile="${RESTIC_BACKUP_STATS_DIR}/$(date '+%Y')-stats.log.csv"
|
|
test -e "$logFile" || install -D -m 0644 <(echo "Date, Snapshot ID, Added, Removed, Snapshot size") "$logFile"
|
|
# DEV-NOTE: using `ex` due `sed` inconsistencies (GNU vs. BSD) and `awk` cannot edit in-place. `ex` does a good job
|
|
printf '1a\n%s\n.\nwq\n' "$(date '+%F %H:%M:%S'), ${snapId}, ${added}, ${removed}, ${snapSize}" | ex "$logFile"
|
|
}
|
|
|
|
# Notify the backup summary stats to the user
|
|
notifyBackupStats() {
|
|
local statsMsg="$1"
|
|
if [ -w "$RESTIC_BACKUP_NOTIFICATION_FILE" ]; then
|
|
echo "$statsMsg" >> "$RESTIC_BACKUP_NOTIFICATION_FILE"
|
|
else
|
|
echo "[WARN] Couldn't write to the backup notification file. File not found or not writable: ${RESTIC_BACKUP_NOTIFICATION_FILE}"
|
|
fi
|
|
}
|
|
|
|
# ------------
|
|
# === Main ===
|
|
# ------------
|
|
|
|
assert_envvars \
|
|
RESTIC_BACKUP_PATHS RESTIC_BACKUP_TAG \
|
|
RESTIC_BACKUP_EXCLUDE_FILE RESTIC_BACKUP_EXTRA_ARGS RESTIC_REPOSITORY RESTIC_VERBOSITY_LEVEL \
|
|
RESTIC_RETENTION_HOURS RESTIC_RETENTION_DAYS RESTIC_RETENTION_MONTHS RESTIC_RETENTION_WEEKS RESTIC_RETENTION_YEARS
|
|
|
|
warn_on_missing_envvars \
|
|
B2_ACCOUNT_ID B2_ACCOUNT_KEY B2_CONNECTIONS \
|
|
RESTIC_PASSWORD_FILE
|
|
|
|
# Convert to arrays, as arrays should be used to build command lines. See https://github.com/koalaman/shellcheck/wiki/SC2086
|
|
IFS=':' read -ra backup_paths <<< "$RESTIC_BACKUP_PATHS"
|
|
|
|
# Convert to array, an preserve spaces. See #111
|
|
backup_extra_args=( )
|
|
if [ -n "$RESTIC_BACKUP_EXTRA_ARGS" ]; then
|
|
while IFS= read -r -d ''; do
|
|
backup_extra_args+=( "$REPLY" )
|
|
done < <(xargs printf '%s\0' <<<"$RESTIC_BACKUP_EXTRA_ARGS")
|
|
fi
|
|
|
|
B2_ARG=
|
|
[ -z "${B2_CONNECTIONS+x}" ] || B2_ARG=(--option b2.connections="$B2_CONNECTIONS")
|
|
|
|
# If you need to run some commands before performing the backup; create this file, put them there and make the file executable.
|
|
PRE_SCRIPT="{{ INSTALL_PREFIX }}/etc/restic/pre_backup.sh"
|
|
test -x "$PRE_SCRIPT" && "$PRE_SCRIPT"
|
|
|
|
# Set up exclude files: global + path-specific ones
|
|
# NOTE that restic will fail the backup if not all listed --exclude-files exist. Thus we should only list them if they are really all available.
|
|
## Global backup configuration.
|
|
exclusion_args=(--exclude-file "$RESTIC_BACKUP_EXCLUDE_FILE")
|
|
## Self-contained backup exclusion files per backup path. E.g. having an USB disk at /mnt/media in RESTIC_BACKUP_PATHS,
|
|
# then a file /mnt/media/.backup_exclude.txt will automatically be detected and used:
|
|
for backup_path in "${backup_paths[@]}"; do
|
|
if [ -f "$backup_path/.backup_exclude.txt" ]; then
|
|
exclusion_args=("${exclusion_args[@]}" --exclude-file "$backup_path/.backup_exclude.txt")
|
|
fi
|
|
done
|
|
|
|
# --one-file-system is not supportd on Windows (=msys).
|
|
FS_ARG=
|
|
test "$OSTYPE" = msys || FS_ARG=--one-file-system
|
|
|
|
# NOTE start all commands in background and wait for them to finish.
|
|
# Reason: bash ignores any signals while child process is executing and thus the trap exit hook is not triggered.
|
|
# However if put in subprocesses, wait(1) waits until the process finishes OR signal is received.
|
|
# Reference: https://unix.stackexchange.com/questions/146756/forward-sigterm-to-child-in-bash
|
|
|
|
# Remove locks from other stale processes to keep the automated backup running.
|
|
restic unlock &
|
|
wait $!
|
|
|
|
# Do the backup!
|
|
# See restic-backup(1) or http://restic.readthedocs.io/en/latest/040_backup.html
|
|
# --one-file-system makes sure we only backup exactly those mounted file systems specified in $RESTIC_BACKUP_PATHS, and thus not directories like /dev, /sys etc.
|
|
# --tag lets us reference these backups later when doing restic-forget.
|
|
restic backup \
|
|
--verbose="$RESTIC_VERBOSITY_LEVEL" \
|
|
$FS_ARG \
|
|
--tag "$RESTIC_BACKUP_TAG" \
|
|
"${B2_ARG[@]}" \
|
|
"${exclusion_args[@]}" \
|
|
"${backup_extra_args[@]}" \
|
|
"${backup_paths[@]}" &
|
|
wait $!
|
|
|
|
# Dereference and delete/prune old backups.
|
|
# See restic-forget(1) or http://restic.readthedocs.io/en/latest/060_forget.html
|
|
# --group-by only the tag and path, and not by hostname. This is because I create a B2 Bucket per host, and if this hostname accidentially change some time, there would now be multiple backup sets.
|
|
restic forget \
|
|
--verbose="$RESTIC_VERBOSITY_LEVEL" \
|
|
--tag "$RESTIC_BACKUP_TAG" \
|
|
"${B2_ARG[@]}" \
|
|
--prune \
|
|
--group-by "paths,tags" \
|
|
--keep-hourly "$RESTIC_RETENTION_HOURS" \
|
|
--keep-daily "$RESTIC_RETENTION_DAYS" \
|
|
--keep-weekly "$RESTIC_RETENTION_WEEKS" \
|
|
--keep-monthly "$RESTIC_RETENTION_MONTHS" \
|
|
--keep-yearly "$RESTIC_RETENTION_YEARS" &
|
|
wait $!
|
|
|
|
# Check repository for errors.
|
|
# NOTE this takes much time (and data transfer from remote repo?), do this in a separate systemd.timer which is run less often.
|
|
#restic check &
|
|
#wait $!
|
|
|
|
echo "Backup & cleaning is done."
|
|
|
|
# (optional) Compute backup summary stats
|
|
if [[ -n "$RESTIC_BACKUP_STATS_DIR" || -n "$RESTIC_BACKUP_NOTIFICATION_FILE" ]]; then
|
|
echo 'Silently computing backup summary stats...'
|
|
latest_snapshots=$(restic snapshots --tag "$RESTIC_BACKUP_TAG" --latest 2 --compact \
|
|
| grep -Ei "^[abcdef0-9]{8} " \
|
|
| awk '{print $1}' \
|
|
| tail -2 \
|
|
| tr '\n' ' ')
|
|
latest_snapshot_diff=$(echo "$latest_snapshots" | xargs restic diff)
|
|
added=$(echo "$latest_snapshot_diff" | grep -i 'added:' | awk '{print $2 " " $3}')
|
|
removed=$(echo "$latest_snapshot_diff" | grep -i 'removed:' | awk '{print $2 " " $3}')
|
|
snapshot_size=$(restic stats latest --tag "$RESTIC_BACKUP_TAG" | grep -i 'total size:' | cut -d ':' -f2 | xargs) # xargs acts as trim
|
|
snapshotId=$(echo "$latest_snapshots" | cut -d ' ' -f2)
|
|
statsMsg="Added: ${added}. Removed: ${removed}. Snap size: ${snapshot_size}"
|
|
|
|
echo "$statsMsg"
|
|
test -n "$RESTIC_BACKUP_STATS_DIR" && logBackupStatsCsv "$snapshotId" "$added" "$removed" "$snapshot_size"
|
|
test -n "$RESTIC_BACKUP_NOTIFICATION_FILE" && notifyBackupStats "$statsMsg"
|
|
fi
|