summaryrefslogtreecommitdiff
path: root/bin/docsync
blob: 5f0c19c1a31a1b13845914e6d85e5ee166c47d0e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
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