| 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)" |