apps/backup/restore.sh 7.8 K raw
1
#!/bin/sh
2
set -eu
3
4
# Restore SQLite backups from Cloudflare R2 into Docker volumes.
5
#
6
# Usage:
7
#   restore.sh <app|all> [--timestamp <ts>] [--list] [--yes]
8
#
9
#   <app>            One of the registered apps, or "all".
10
#   --timestamp <ts> Restore a specific backup (e.g. 2026-04-04T060000Z).
11
#                    Default: latest. The .sqlite.gz suffix is optional.
12
#   --list           List available backups for the app(s) and exit.
13
#   --yes            Skip the interactive confirmation prompt.
14
#
15
# Env (same as backup.sh): R2_ENDPOINT, R2_BUCKET, AWS_ACCESS_KEY_ID,
16
# AWS_SECRET_ACCESS_KEY. Read from ./.env (or $ENV_FILE) if set; real env wins.
17
#
18
# Volume names are read ONLY from the root docker-compose.yml (the `name:`
19
# field of each external volume), not from .env. Override the compose file
20
# location with $COMPOSE_FILE. Missing volumes are created automatically.
21
#
22
# Run this on the HOST. It shells out to `docker run` to write into volumes;
23
# the backup container itself mounts those volumes read-only.
24
25
# Load .env from the script's directory (or $ENV_FILE) if present. Existing
26
# environment variables take precedence over the file.
27
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
28
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}"
29
if [ -f "$ENV_FILE" ]; then
30
  while IFS= read -r line || [ -n "$line" ]; do
31
    case "$line" in
32
      ''|\#*) continue ;;
33
    esac
34
    key="${line%%=*}"
35
    case "$key" in
36
      *[!A-Za-z0-9_]*|'') continue ;;
37
    esac
38
    # Only set if not already in the environment.
39
    if [ -z "$(eval "printf '%s' \"\${$key:-}\"")" ]; then
40
      val="${line#*=}"
41
      export "$key=$val"
42
    fi
43
  done < "$ENV_FILE"
44
fi
45
46
BUCKET="${R2_BUCKET:-andromeda-backups}"
47
48
# Root docker-compose.yml is the single source of truth for volume names.
49
COMPOSE_FILE="${COMPOSE_FILE:-$SCRIPT_DIR/../../docker-compose.yml}"
50
51
# app | volume-internal filename | compose volume key | fallback volume name
52
# The compose key is looked up in COMPOSE_FILE; the fallback is used only if
53
# the key (or compose file) is missing.
54
APPS="jotts:jotts.sqlite:jotts_data:jotts_jotts-data
55
sipp:sipp.sqlite:sipp_data:sipp-rust_sipp-data
56
cellar:cellar.sqlite:cellar_data:cellar_cellar_data
57
posts:posts.sqlite:posts_data:posts_posts-data
58
feeds:feeds.sqlite:feeds_data:feeds_feeds_data
59
library:library.sqlite:library_data:library_library-data
60
bookmarks:bookmarks.sqlite:bookmarks_data:bookmarks_bookmarks-data
61
parcels:parcels.db:parcels_data:parcels_parcels_data
62
easel:easel.sqlite:easel_data:easel_easel-data"
63
64
RESTORE_IMAGE="debian:bookworm-slim"
65
66
usage() {
67
  sed -n '6,19p' "$0" | sed 's/^# \{0,1\}//'
68
  exit "${1:-0}"
69
}
70
71
die() {
72
  echo "ERROR: $*" >&2
73
  exit 1
74
}
75
76
# Look up a field for an app from the APPS registry.
77
# $1 app name, $2 field index (2=file, 3=compose key, 4=fallback volume)
78
app_field() {
79
  echo "$APPS" | while IFS=: read -r name file key def; do
80
    [ "$name" = "$1" ] || continue
81
    case "$2" in
82
      2) echo "$file" ;;
83
      3) echo "$key" ;;
84
      4) echo "$def" ;;
85
    esac
86
    break
87
  done
88
}
89
90
app_exists() {
91
  echo "$APPS" | cut -d: -f1 | grep -qx "$1"
92
}
93
94
# Read the external `name:` for a volume key from the compose file's top-level
95
# `volumes:` block. Echoes nothing if the file or key is absent.
96
compose_volume_name() {
97
  key="$1"
98
  [ -f "$COMPOSE_FILE" ] || return 0
99
  awk -v key="$key" '
100
    /^volumes:/             { invol=1; next }
101
    invol && /^[^[:space:]]/ { invol=0 }
102
    invol && $0 ~ "^  "key":[[:space:]]*$" { found=1; next }
103
    found && /^[[:space:]]*name:[[:space:]]/ {
104
      sub(/^[[:space:]]*name:[[:space:]]*/, ""); print; exit
105
    }
106
    found && /^  [^[:space:]]/ { exit }   # next volume block, no name set
107
  ' "$COMPOSE_FILE"
108
}
109
110
# Resolve the Docker volume name for an app from the root compose file,
111
# falling back to the registry default only if compose has no entry.
112
resolve_volume() {
113
  app="$1"
114
  key=$(app_field "$app" 3)
115
  def=$(app_field "$app" 4)
116
  name=$(compose_volume_name "$key")
117
  if [ -n "$name" ]; then
118
    echo "$name"
119
  else
120
    echo "$def"
121
  fi
122
}
123
124
list_backups() {
125
  app="$1"
126
  echo "Backups for $app (s3://$BUCKET/$app/):"
127
  aws s3 ls "s3://$BUCKET/$app/" --endpoint-url "$R2_ENDPOINT" \
128
    | awk '{print "  " $1 " " $2 "  " $4}' \
129
    || echo "  (none or unreachable)"
130
}
131
132
# Echo the object key (filename only) to restore for an app.
133
resolve_key() {
134
  app="$1"
135
  if [ -n "$TIMESTAMP" ]; then
136
    case "$TIMESTAMP" in
137
      *.sqlite.gz) echo "$TIMESTAMP" ;;
138
      *) echo "${TIMESTAMP}.sqlite.gz" ;;
139
    esac
140
    return
141
  fi
142
  aws s3 ls "s3://$BUCKET/$app/" --endpoint-url "$R2_ENDPOINT" \
143
    | awk '{print $4}' | grep -v '^$' | sort | tail -1
144
}
145
146
restore_app() {
147
  app="$1"
148
149
  key=$(resolve_key "$app")
150
  if [ -z "$key" ]; then
151
    echo "WARN: no backups found for $app, skipping"
152
    return 1
153
  fi
154
155
  volume=$(resolve_volume "$app")
156
  internal_file=$(app_field "$app" 2)
157
  src="s3://$BUCKET/$app/$key"
158
159
  if ! docker volume inspect "$volume" >/dev/null 2>&1; then
160
    echo "$(date -u) Creating docker volume '$volume' for $app ..."
161
    docker volume create "$volume" >/dev/null \
162
      || { echo "WARN: failed to create volume '$volume' for $app, skipping"; return 1; }
163
  fi
164
165
  if [ "$ASSUME_YES" != "1" ]; then
166
    echo
167
    echo "About to restore:"
168
    echo "  app:    $app"
169
    echo "  source: $src"
170
    echo "  volume: $volume -> /$internal_file"
171
    printf "Overwrite live data? [y/N] "
172
    read -r answer
173
    case "$answer" in
174
      y|Y|yes|YES) ;;
175
      *) echo "Skipped $app"; return 0 ;;
176
    esac
177
  fi
178
179
  workdir=$(mktemp -d)
180
  trap 'rm -rf "$workdir"' EXIT INT TERM
181
182
  echo "$(date -u) Downloading $src ..."
183
  aws s3 cp "$src" "$workdir/$key" --endpoint-url "$R2_ENDPOINT" \
184
    || { echo "WARN: download failed for $app"; rm -rf "$workdir"; trap - EXIT INT TERM; return 1; }
185
186
  echo "$(date -u) Decompressing ..."
187
  gunzip "$workdir/$key"
188
  decompressed="$workdir/${key%.gz}"
189
190
  echo "$(date -u) Verifying integrity ..."
191
  check=$(sqlite3 "$decompressed" 'PRAGMA integrity_check;' 2>&1 || true)
192
  if [ "$check" != "ok" ]; then
193
    echo "WARN: integrity check failed for $app: $check"
194
    rm -rf "$workdir"; trap - EXIT INT TERM
195
    return 1
196
  fi
197
198
  echo "$(date -u) Writing into volume $volume ..."
199
  docker run --rm \
200
    -v "$volume:/data" \
201
    -v "$workdir:/backup:ro" \
202
    "$RESTORE_IMAGE" \
203
    cp "/backup/$(basename "$decompressed")" "/data/$internal_file"
204
205
  rm -rf "$workdir"
206
  trap - EXIT INT TERM
207
208
  echo "$(date -u) OK: $app restored from $key"
209
  echo "WARNING: stop the '$app' service before restoring and restart it after for a clean"
210
  echo "         restore. This script does not stop or start services."
211
  return 0
212
}
213
214
# --- arg parsing ---------------------------------------------------------------
215
216
[ $# -ge 1 ] || usage 1
217
218
TARGET=""
219
TIMESTAMP=""
220
DO_LIST=0
221
ASSUME_YES=0
222
223
while [ $# -gt 0 ]; do
224
  case "$1" in
225
    -h|--help) usage 0 ;;
226
    --list) DO_LIST=1 ;;
227
    --yes|-y) ASSUME_YES=1 ;;
228
    --timestamp)
229
      shift
230
      [ $# -ge 1 ] || die "--timestamp requires a value"
231
      TIMESTAMP="$1"
232
      ;;
233
    --timestamp=*) TIMESTAMP="${1#*=}" ;;
234
    -*) die "unknown option: $1" ;;
235
    *)
236
      [ -z "$TARGET" ] || die "unexpected argument: $1"
237
      TARGET="$1"
238
      ;;
239
  esac
240
  shift
241
done
242
243
[ -n "$TARGET" ] || usage 1
244
: "${R2_ENDPOINT:?R2_ENDPOINT is required}"
245
246
if [ "$TARGET" = "all" ]; then
247
  if [ -n "$TIMESTAMP" ] && [ "$DO_LIST" != "1" ]; then
248
    die "--timestamp cannot be combined with 'all' (timestamps differ per app)"
249
  fi
250
  TARGETS=$(echo "$APPS" | cut -d: -f1)
251
else
252
  app_exists "$TARGET" || die "unknown app: $TARGET (valid: $(echo "$APPS" | cut -d: -f1 | tr '\n' ' '))"
253
  TARGETS="$TARGET"
254
fi
255
256
if [ "$DO_LIST" = "1" ]; then
257
  for app in $TARGETS; do
258
    list_backups "$app"
259
  done
260
  exit 0
261
fi
262
263
failures=0
264
total=0
265
for app in $TARGETS; do
266
  total=$((total + 1))
267
  restore_app "$app" || failures=$((failures + 1))
268
done
269
270
if [ "$failures" -gt 0 ] && [ "$failures" -eq "$total" ]; then
271
  die "all restores failed ($failures/$total)"
272
fi
273
echo "$(date -u) Restore complete ($((total - failures))/$total succeeded)"