diff options
Diffstat (limited to 'bin/docsync')
| -rwxr-xr-x | bin/docsync | 314 |
1 files changed, 314 insertions, 0 deletions
diff --git a/bin/docsync b/bin/docsync new file mode 100755 index 0000000..5f0c19c --- /dev/null +++ b/bin/docsync @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/docsync" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/docsync" +INCLUDE_FILE="$CONFIG_DIR/include" +EXCLUDE_FILE="$CONFIG_DIR/exclude" + +# The repository is reached over SFTP. $CONFIG_DIR/config sets SSH_HOST to a +# Host in ~/.ssh/config that logs in without a prompt (a key, BatchMode-safe), +# and may set REPO_PATH, the repository's path on that host. The password +# file holds the repository's password, kept in a file so the timer never +# waits on a prompt; restic's other keys (a paper recovery passphrase, say) +# still open the repository as usual. +SSH_HOST= +REPO_PATH=repo +# shellcheck source=/dev/null +[[ -f "$CONFIG_DIR/config" ]] && . "$CONFIG_DIR/config" +export RESTIC_REPOSITORY="sftp:$SSH_HOST:$REPO_PATH" +export RESTIC_PASSWORD_FILE="$CONFIG_DIR/restic-password" + +# Every docsync snapshot carries this tag, and snapshots are grouped by host +# and tag rather than by path list, so adding or dropping a path continues the +# same history (and retention) instead of starting a new one. +TAG=docsync +GROUP_BY=host,tags + +# Retention for 'docsync forget': every hourly snapshot for two days, then one +# a day for a month, one a week for a year, and one a month for good. +KEEP=(--keep-hourly 48 --keep-daily 30 --keep-weekly 52 --keep-monthly unlimited) + +# How much of the stored data 'docsync check' downloads and verifies per run. +CHECK_SUBSET=5% + +# Desktop notifications (only when run by a systemd timer): a failure while the +# server is reachable is worth hearing about soon, being offline only once no +# backup has landed for a day. At most one notification per window either way. +ERROR_NOTIFY_EVERY=$((6 * 3600)) +OFFLINE_NOTIFY_AFTER=$((24 * 3600)) + +usage() { + cat <<EOF +Usage: docsync COMMAND + + backup take a snapshot now (the hourly timer runs this) + forget thin out old snapshots and free their space (weekly timer) + check verify the repository and $CHECK_SUBSET of its data (monthly timer) + status when the last backup landed, and the latest snapshots + paths the paths and exclude patterns a backup would use + manifest rewrite the list of files in the latest snapshot (backup does this) + restic ARGS any restic command against the repository, e.g. + docsync restic snapshots + docsync restic mount ~/mnt/docsync + +Setup, all in $CONFIG_DIR: + config shell assignments: SSH_HOST=<a Host in ~/.ssh/config>, + and optionally REPO_PATH=<path on it> (default: repo) + restic-password the repository's password, mode 600 + include one path per line, relative to ~ unless it starts with / + exclude restic exclude patterns; a leading ~/ means your home +Blank lines and lines starting with # are ignored in include and exclude. +Create the repository once with: docsync restic init +The systemd units beside this script run backup hourly, forget weekly and +check monthly; they expect it at ~/.local/bin/docsync. + +State for other tools (Emacs) is in $STATE_DIR: + last-success epoch seconds of the last backup that saved a snapshot + last-result "EPOCH STATUS MESSAGE", STATUS one of ok, warning, offline, error + missing include paths that didn't exist at the last backup + manifest every file in the latest snapshot as TYPE SIZE MTIME PATH, + tab-separated (jq @tsv escaping), after a "# snapshot ID TIME" + line; Emacs compares it with the disk to mark what's changed +EOF +} + +die() { echo "docsync: $*" >&2; exit 1; } + +now() { date +%s; } + +# A list file's entries, without comments, blank lines or surrounding space. +list_lines() { + [[ -f "$1" ]] || return 0 + sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$1" +} + +include_paths() { + local p + list_lines "$INCLUDE_FILE" | while IFS= read -r p; do + p="${p#\~/}" + [[ "$p" == /* ]] || p="$HOME/$p" + [[ "$p" == / ]] || p="${p%/}" + printf '%s\n' "$p" + done +} + +exclude_patterns() { + local p + list_lines "$EXCLUDE_FILE" | while IFS= read -r p; do + [[ "$p" == "~/"* ]] && p="$HOME/${p#\~/}" + printf '%s\n' "$p" + done +} + +# ---------------------------------------------------------------- state + +put_state() { # NAME LINE... + if (( $# > 1 )); then printf '%s\n' "${@:2}"; fi > "$STATE_DIR/$1.tmp" + mv "$STATE_DIR/$1.tmp" "$STATE_DIR/$1" +} + +get_state() { # NAME DEFAULT + if [[ -s "$STATE_DIR/$1" ]]; then head -n1 "$STATE_DIR/$1"; else printf '%s\n' "$2"; fi +} + +notify() { # WINDOW URGENCY MESSAGE + [[ -n "${INVOCATION_ID:-}" ]] || return 0 + local last; last=$(get_state notified 0) + (( $(now) - last >= $1 )) || return 0 + notify-send -a docsync -u "$2" docsync "$3" 2>/dev/null || true + put_state notified "$(now)" +} + +# A run that dies mid-operation (the server dropping off during a prune, say) +# leaves its lock behind, and restic never clears one by itself: every later +# run would wait out --retry-lock and fail. Without --remove-all, unlock +# removes only stale locks, ones whose process has exited or that haven't been +# refreshed for 30 minutes (a running restic refreshes its lock every 5). +unlock_stale() { + restic unlock || true +} + +# A cheap "is the server reachable" probe. With an SFTP-only account, whose +# forced command answers any login with an SFTP server that exits at EOF, this +# still returns as soon as the connection is up. +reachable() { + ssh -o BatchMode=yes "$SSH_HOST" true </dev/null >/dev/null 2>&1 +} + +# Not a failure (a laptop's network comes and goes, and the next hourly run +# tries again), so only worth a notification once no backup has landed for a day. +backup_offline() { + put_state last-result "$(now) offline can't reach $SSH_HOST" + if (( $(now) - $(get_state last-success 0) >= OFFLINE_NOTIFY_AFTER )); then + notify "$OFFLINE_NOTIFY_AFTER" normal "No backup for over a day: can't reach $SSH_HOST." + fi + echo "docsync: can't reach $SSH_HOST, skipping this backup" >&2 + exit 0 +} + +ago() { # EPOCH + local s=$(( $(now) - $1 )) + if (( s < 90 )); then echo "just now" + elif (( s < 5400 )); then echo "$(( s / 60 )) min ago" + elif (( s < 172800 )); then echo "$(( s / 3600 )) h ago" + else echo "$(( s / 86400 )) days ago" + fi +} + +# ---------------------------------------------------------------- commands + +# Every file in the latest snapshot with its size and modification time, so +# Emacs can tell what has changed since without asking the repository. A +# failure keeps the old manifest: Emacs then shows more as changed than has, +# never less. +cmd_manifest() { + mkdir -p "$STATE_DIR" + local out="$STATE_DIR/manifest" + if restic ls --json --tag "$TAG" latest 2>/dev/null | jq -r ' + if .struct_type == "snapshot" then "# snapshot \(.id) \(.time)" + elif .struct_type == "node" then [.type, (.size // 0), .mtime, .path] | @tsv + else empty end' > "$out.tmp"; then + mv "$out.tmp" "$out" + else + rm -f "$out.tmp" + echo "docsync: couldn't list the latest snapshot; the manifest is stale" >&2 + return 1 + fi +} + +cmd_backup() { + mkdir -p "$STATE_DIR" + exec 9>"$STATE_DIR/lock" + flock -n 9 || { echo "docsync: a backup is already running" >&2; exit 0; } + + [[ -f "$RESTIC_PASSWORD_FILE" ]] || + die "no password file; put the repository's password in $RESTIC_PASSWORD_FILE (mode 600)" + [[ -f "$INCLUDE_FILE" ]] || die "nothing to back up: $INCLUDE_FILE is missing" + + # Global, not local: the EXIT trap runs after this function has returned. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + local -a present=() missing=() + local p + while IFS= read -r p; do + if [[ -e "$p" || -L "$p" ]]; then present+=("$p"); else missing+=("$p"); fi + done < <(include_paths) + put_state missing "${missing[@]}" + for p in "${missing[@]}"; do echo "docsync: listed but missing: $p" >&2; done + + if (( ${#present[@]} == 0 )); then + put_state last-result "$(now) error none of the listed paths exist" + notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: none of the listed paths exist." + die "none of the paths in $INCLUDE_FILE exist" + fi + printf '%s\n' "${present[@]}" > "$tmp/include" + exclude_patterns > "$tmp/exclude" + + reachable || backup_offline + unlock_stale + local rc=0 + restic backup \ + --files-from-verbatim "$tmp/include" \ + --exclude-file "$tmp/exclude" \ + --exclude-caches \ + --tag "$TAG" --group-by "$GROUP_BY" \ + --skip-if-unchanged \ + --retry-lock 1h \ + 2>"$tmp/err" || rc=$? + # Echoed by bash rather than cat: journald files a line under its unit by + # looking up the writing process, and cat has exited by then, so restic's + # error would be missing from journalctl --user -u docsync-backup. + [[ ! -s "$tmp/err" ]] || printf '%s\n' "$(<"$tmp/err")" >&2 + local err; err=$(grep -v '^[[:space:]]*$' "$tmp/err" | tail -n1 || true) + + case $rc in + 0|3) put_state last-success "$(now)" + rm -f "$STATE_DIR/notified" + cmd_manifest || true ;; + esac + case $rc in + 0) if (( ${#missing[@]} )); then + put_state last-result "$(now) warning ${#missing[@]} listed path(s) missing" + notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but ${#missing[@]} listed path(s) no longer exist." + else + put_state last-result "$(now) ok" + fi ;; + 3) put_state last-result "$(now) warning some files couldn't be read: $err" + notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but some files couldn't be read." ;; + *) # Losing the server partway through is being offline, not an error. + reachable || backup_offline + put_state last-result "$(now) error ${err:-restic exited with $rc}" + notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: ${err:-restic exited with $rc}" + exit "$rc" ;; + esac +} + +# forget and check: on failure under a timer, say so, unless the server dropped +# off; the next scheduled run clears the stale lock that leaves and starts over. +maintenance() { # WHAT RESTIC-ARGS... + local what=$1; shift + unlock_stale + if ! restic "$@" --retry-lock 1h; then + [[ -n "${INVOCATION_ID:-}" ]] && reachable && + notify-send -a docsync -u critical docsync "docsync $what failed; see journalctl --user -u docsync-$what" 2>/dev/null + exit 1 + fi +} + +cmd_forget() { + maintenance forget forget --tag "$TAG" --group-by "$GROUP_BY" "${KEEP[@]}" --prune +} + +cmd_check() { + maintenance check check --read-data-subset "$CHECK_SUBSET" +} + +cmd_status() { + local last result + last=$(get_state last-success "") + result=$(get_state last-result "") + if [[ -n "$last" ]]; then + echo "Last backup: $(date -d "@$last" '+%F %R') ($(ago "$last"))" + else + echo "Last backup: never" + fi + if [[ -n "$result" ]]; then + read -r when status msg <<<"$result" + echo "Last run: $(date -d "@$when" '+%F %R') $status${msg:+: $msg}" + fi + if [[ -s "$STATE_DIR/missing" ]]; then + echo "Missing:"; sed 's/^/ /' "$STATE_DIR/missing" + fi + echo + restic snapshots --tag "$TAG" --group-by "$GROUP_BY" --latest 5 --compact || + echo "(couldn't list snapshots; is $SSH_HOST reachable?)" +} + +cmd_paths() { + local p + echo "Include:" + include_paths | while IFS= read -r p; do + if [[ -e "$p" || -L "$p" ]]; then echo " $p"; else echo " $p (missing)"; fi + done + echo "Exclude:" + exclude_patterns | sed 's/^/ /' +} + +case "${1:-}" in + backup|forget|check|status|manifest|restic) + [[ -n "$SSH_HOST" ]] || die "set SSH_HOST in $CONFIG_DIR/config" ;; +esac + +case "${1:-}" in + backup) cmd_backup ;; + forget) cmd_forget ;; + check) cmd_check ;; + status) cmd_status ;; + paths) cmd_paths ;; + manifest) cmd_manifest ;; + restic) shift; exec restic "$@" ;; + -h|--help|help|"") usage ;; + *) usage >&2; exit 2 ;; +esac |