restic-backup: add backup script
This commit is contained in:
Executable
+211
@@ -0,0 +1,211 @@
|
||||
#!/bin/bash
|
||||
# restic-backup — daily restic backup driver, meant to be polled every 15 min by cron.
|
||||
#
|
||||
# restic-backup run the periodic check (prompt + backup if a daily backup is due)
|
||||
# restic-backup run same as above (explicit)
|
||||
# restic-backup status print the dwmblocks status symbol (running -> symbol, idle -> empty line)
|
||||
# restic-backup force skip the "is it due?" check and back up now (still prompts)
|
||||
#
|
||||
# dwmblocks integration: add a block whose command is `restic-backup status` on signal
|
||||
# RTMIN+14, e.g. in ~/src/dwmblocks/config.h:
|
||||
# {"restic-backup status", 0, 14},
|
||||
# The script refreshes that block itself via `pkill -RTMIN+14 dwmblocks`.
|
||||
|
||||
set -u
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
REPO="sftp:root@bocken.org:/backups/t14"
|
||||
HOST="bocken.org" # backup server, checked for reachability before we prompt
|
||||
PORT=22 # sftp/ssh port
|
||||
INCLUDE_FILE="$HOME/.config/restic/include_files"
|
||||
EXCLUDE_FILE="$HOME/.config/restic/exclude_files"
|
||||
|
||||
SIGNAL=14 # dwmblocks RTMIN+SIGNAL (unused in current config.h)
|
||||
SYMBOL=$'' # status symbol shown while a backup is running (nf database; edit to taste)
|
||||
LOW_BATTERY=30 # %, below this on battery we postpone and notify
|
||||
|
||||
STATE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/restic-backup"
|
||||
LAST_SUCCESS="$STATE_DIR/last_success" # holds YYYY-MM-DD of last successful backup
|
||||
RUNNING_FLAG="$STATE_DIR/running" # present only while restic is running
|
||||
LOCK_FILE="$STATE_DIR/lock" # flock target, serialises cron invocations
|
||||
SNOOZE_FILE="$STATE_DIR/snooze" # epoch until which we must not re-prompt
|
||||
LOG_FILE="$STATE_DIR/backup.log"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Repository password comes from pass(1) at "Misc/restic", supplied to restic
|
||||
# below via --password-command. (pass -> gpg needs the user's gpg-agent, which
|
||||
# the graphical-env import / DISPLAY recovery below makes reachable from cron.)
|
||||
PASS_CMD="pass show Misc/restic"
|
||||
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status subcommand — used by dwmblocks. Must be cheap and side-effect free.
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "${1:-run}" = "status" ]; then
|
||||
if [ -f "$RUNNING_FLAG" ]; then
|
||||
printf '%s\n' "$SYMBOL"
|
||||
else
|
||||
printf '\n'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Everything below runs from cron, which has no graphical/session environment.
|
||||
# Recover DISPLAY / XAUTHORITY / DBUS so dmenu and notify-send work.
|
||||
# ---------------------------------------------------------------------------
|
||||
import_graphical_env() {
|
||||
local pid var val
|
||||
# Find a process that belongs to our graphical session.
|
||||
pid=$(pgrep -u "$(id -u)" -nx dwm 2>/dev/null) \
|
||||
|| pid=$(pgrep -u "$(id -u)" -nx dwmblocks 2>/dev/null) \
|
||||
|| pid=$(pgrep -u "$(id -u)" -nx Xorg 2>/dev/null)
|
||||
[ -n "$pid" ] && [ -r "/proc/$pid/environ" ] || return 0
|
||||
for var in DISPLAY XAUTHORITY DBUS_SESSION_BUS_ADDRESS; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
val=$(tr '\0' '\n' < "/proc/$pid/environ" | grep "^$var=" | head -n1 | cut -d= -f2-)
|
||||
[ -n "$val" ] && export "$var=$val"
|
||||
fi
|
||||
done
|
||||
}
|
||||
import_graphical_env
|
||||
export DISPLAY="${DISPLAY:-:0}"
|
||||
export XAUTHORITY="${XAUTHORITY:-$HOME/.Xauthority}"
|
||||
|
||||
notify() { notify-send -a "restic backup" "$@"; }
|
||||
|
||||
refresh_bar() { pkill "-RTMIN+$SIGNAL" dwmblocks 2>/dev/null; }
|
||||
|
||||
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG_FILE"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Battery check. Returns 0 = ok to back up, 1 = postpone (low battery on AC off).
|
||||
# ---------------------------------------------------------------------------
|
||||
battery_ok() {
|
||||
local ac on_ac=0 cap=100 bat
|
||||
for ac in /sys/class/power_supply/A{C,DP}*; do
|
||||
[ -r "$ac/online" ] && on_ac=$(cat "$ac/online") && break
|
||||
done
|
||||
[ "$on_ac" = "1" ] && return 0 # plugged in -> always fine
|
||||
|
||||
for bat in /sys/class/power_supply/BAT*; do
|
||||
[ -r "$bat/capacity" ] && cap=$(cat "$bat/capacity") && break
|
||||
done
|
||||
[ "$cap" -ge "$LOW_BATTERY" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Is the backup server reachable? TCP connect to the sftp port, no extra deps.
|
||||
# ---------------------------------------------------------------------------
|
||||
host_reachable() {
|
||||
timeout 5 bash -c "exec 3<>/dev/tcp/$HOST/$PORT" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Is a daily backup due? Due unless we already succeeded today.
|
||||
# ---------------------------------------------------------------------------
|
||||
backup_due() {
|
||||
[ -f "$LAST_SUCCESS" ] || return 0
|
||||
[ "$(cat "$LAST_SUCCESS")" != "$(date '+%F')" ]
|
||||
}
|
||||
|
||||
# Currently snoozed by an earlier decline? (cleared automatically once it lapses)
|
||||
snoozed() {
|
||||
[ -f "$SNOOZE_FILE" ] || return 1
|
||||
[ "$(date '+%s')" -lt "$(cat "$SNOOZE_FILE")" ]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run restic. Sets the running flag, repaints the bar, restores on exit.
|
||||
# ---------------------------------------------------------------------------
|
||||
do_backup() {
|
||||
: > "$RUNNING_FLAG"
|
||||
refresh_bar
|
||||
# Always clear the flag and repaint, no matter how we exit.
|
||||
trap 'rm -f "$RUNNING_FLAG"; refresh_bar' EXIT
|
||||
|
||||
log "backup started"
|
||||
# restic command uses paths relative to $HOME in the original invocation.
|
||||
cd "$HOME" || exit 1
|
||||
if restic backup -r "$REPO" \
|
||||
--password-command "$PASS_CMD" \
|
||||
--files-from "$INCLUDE_FILE" \
|
||||
--exclude-file "$EXCLUDE_FILE" >> "$LOG_FILE" 2>&1; then
|
||||
date '+%F' > "$LAST_SUCCESS"
|
||||
rm -f "$SNOOZE_FILE"
|
||||
log "backup finished OK"
|
||||
notify "✅ Backup complete" "restic snapshot stored on bocken.org:/backups/t14"
|
||||
else
|
||||
log "backup FAILED (see $LOG_FILE)"
|
||||
notify -u critical "❌ Backup failed" "restic exited non-zero. See $LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main: the periodic (cron) entrypoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
main() {
|
||||
local force="${1:-}"
|
||||
|
||||
# Only ever decide whether a backup is due if none is in progress / pending.
|
||||
# flock -n serialises the 15-minute cron invocations: while a prompt is open
|
||||
# or restic is running, later invocations just exit instead of stacking.
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -n 9 || exit 0
|
||||
|
||||
if [ "$force" != "force" ] && ! backup_due; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Honour a snooze the user picked after a previous decline ("force" overrides).
|
||||
if [ "$force" != "force" ] && snoozed; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! battery_ok; then
|
||||
notify -u critical "🔌 Backup postponed" \
|
||||
"A daily backup is due but the battery is low. Plug in the laptop to start it."
|
||||
log "postponed: low battery"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Don't pester the user if the server can't be reached; just retry next tick.
|
||||
if ! host_reachable; then
|
||||
log "postponed: $HOST:$PORT unreachable"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ask before doing the (potentially long, bandwidth-heavy) backup.
|
||||
# `prompt` (~/.local/bin) runs its 2nd arg only when "Yes" is picked; we give
|
||||
# it a no-op so its exit status reports the choice while do_backup stays here
|
||||
# in the parent, under the flock we already hold.
|
||||
if prompt "Daily restic backup is due — run it now?" true; then
|
||||
do_backup
|
||||
else
|
||||
# Declined: ask how long to wait before prompting again.
|
||||
local sel until
|
||||
sel=$(printf 'in 15 minutes\nin 1 hour\nin 4 hours\nnext day' \
|
||||
| dmenu -i -p "Backup postponed — re-attempt when?")
|
||||
case "$sel" in
|
||||
"in 15 minutes") until=$(date -d '+15 minutes' '+%s') ;;
|
||||
"in 1 hour") until=$(date -d '+1 hour' '+%s') ;;
|
||||
"in 4 hours") until=$(date -d '+4 hours' '+%s') ;;
|
||||
"next day") until=$(date -d 'tomorrow 00:00' '+%s') ;;
|
||||
*) until=0 ;; # dismissed -> no snooze, retry next tick
|
||||
esac
|
||||
if [ "$until" -gt 0 ]; then
|
||||
printf '%s\n' "$until" > "$SNOOZE_FILE"
|
||||
log "user declined; snoozed $sel (until $(date -d "@$until" '+%F %T'))"
|
||||
else
|
||||
rm -f "$SNOOZE_FILE"
|
||||
log "user declined; no snooze chosen, retry next tick"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
main "${1:-run}"
|
||||
Reference in New Issue
Block a user