feat: added restore to backup
bb16bbfb
3 file(s) · +265 −22
| 7 | 7 | COPY apps/backup/backup.sh /usr/local/bin/backup.sh |
|
| 8 | 8 | RUN chmod +x /usr/local/bin/backup.sh |
|
| 9 | 9 | ||
| 10 | + | # restore.sh ships for reference; run it on the HOST (it shells out to `docker run` |
|
| 11 | + | # and the backup container mounts the data volumes read-only). |
|
| 12 | + | COPY apps/backup/restore.sh /usr/local/bin/restore.sh |
|
| 13 | + | RUN chmod +x /usr/local/bin/restore.sh |
|
| 14 | + | ||
| 10 | 15 | COPY apps/backup/crontab /etc/cron.d/backup-cron |
|
| 11 | 16 | RUN chmod 0644 /etc/cron.d/backup-cron && crontab /etc/cron.d/backup-cron |
|
| 12 | 17 |
| 110 | 110 | ||
| 111 | 111 | ## Restoring from a Backup |
|
| 112 | 112 | ||
| 113 | - | 1. List available backups for a service (e.g. `jotts`): |
|
| 113 | + | Use `restore.sh` to download a backup from R2 and write it into the target Docker volume. |
|
| 114 | + | Run it on the **host** (it shells out to `docker run`; the backup container mounts the data |
|
| 115 | + | volumes read-only). It reads the same `.env` as the backup (`R2_ENDPOINT`, `R2_BUCKET`, |
|
| 116 | + | AWS keys, optional `*_VOLUME` overrides) — `source .env` first, or prefix the env inline. |
|
| 114 | 117 | ||
| 115 | 118 | ```sh |
|
| 116 | - | aws s3 ls s3://andromeda-backups/jotts/ --endpoint-url https://<account-id>.r2.cloudflarestorage.com |
|
| 119 | + | restore.sh <app|all> [--timestamp <ts>] [--list] [--yes] |
|
| 117 | 120 | ``` |
|
| 118 | 121 | ||
| 119 | - | 2. Download the backup you want to restore: |
|
| 122 | + | - `<app>` — one of: `jotts sipp cellar posts feeds library bookmarks parcels easel`, or `all`. |
|
| 123 | + | - `--timestamp <ts>` — restore a specific backup (e.g. `2026-04-04T060000Z`); the |
|
| 124 | + | `.sqlite.gz` suffix is optional. Default: the latest backup. Not valid with `all`. |
|
| 125 | + | - `--list` — list available backups for the app(s) and exit (no restore). |
|
| 126 | + | - `--yes` — skip the interactive confirmation prompt. |
|
| 120 | 127 | ||
| 121 | - | ```sh |
|
| 122 | - | aws s3 cp s3://andromeda-backups/jotts/2026-04-04T060000Z.sqlite.gz ./restore.sqlite.gz \ |
|
| 123 | - | --endpoint-url https://<account-id>.r2.cloudflarestorage.com |
|
| 124 | - | ``` |
|
| 128 | + | > **Warning:** Restoring overwrites live data. Stop the target service before restoring and |
|
| 129 | + | > restart it after for a clean restore — `restore.sh` does **not** stop or start services. |
|
| 125 | 130 | ||
| 126 | - | 3. Decompress it: |
|
| 131 | + | Examples: |
|
| 127 | 132 | ||
| 128 | 133 | ```sh |
|
| 129 | - | gunzip restore.sqlite.gz |
|
| 130 | - | ``` |
|
| 134 | + | # Load credentials/volume overrides |
|
| 135 | + | set -a; . ./.env; set +a |
|
| 131 | 136 | ||
| 132 | - | 4. Stop the target service so nothing is writing to the database: |
|
| 137 | + | # See what's available |
|
| 138 | + | restore.sh jotts --list |
|
| 133 | 139 | ||
| 134 | - | ```sh |
|
| 135 | - | docker compose -f /path/to/jotts/docker-compose.yml down |
|
| 136 | - | ``` |
|
| 140 | + | # Restore the latest jotts backup (prompts for confirmation) |
|
| 141 | + | restore.sh jotts |
|
| 137 | 142 | ||
| 138 | - | 5. Copy the restored database into the volume: |
|
| 143 | + | # Restore a specific backup, no prompt |
|
| 144 | + | restore.sh jotts --timestamp 2026-04-04T060000Z --yes |
|
| 139 | 145 | ||
| 140 | - | ```sh |
|
| 141 | - | docker run --rm -v jotts_jotts-data:/data -v $(pwd):/backup debian:bookworm-slim \ |
|
| 142 | - | cp /backup/restore.sqlite /data/jotts.sqlite |
|
| 146 | + | # Restore every app's latest backup |
|
| 147 | + | restore.sh all |
|
| 143 | 148 | ``` |
|
| 144 | 149 | ||
| 145 | - | 6. Restart the service: |
|
| 150 | + | For each app the script: resolves the backup key (latest or `--timestamp`), downloads and |
|
| 151 | + | decompresses it, runs `PRAGMA integrity_check`, confirms the target volume exists, then |
|
| 152 | + | copies the database to the volume root via a throwaway `debian:bookworm-slim` container. |
|
| 146 | 153 | ||
| 147 | - | ```sh |
|
| 148 | - | docker compose -f /path/to/jotts/docker-compose.yml up -d |
|
| 149 | - | ``` |
|
| 154 | + | ### Manual restore (fallback) |
|
| 155 | + | ||
| 156 | + | 1. List backups: `aws s3 ls s3://andromeda-backups/jotts/ --endpoint-url https://<account-id>.r2.cloudflarestorage.com` |
|
| 157 | + | 2. Download: `aws s3 cp s3://andromeda-backups/jotts/<timestamp>.sqlite.gz ./restore.sqlite.gz --endpoint-url <endpoint>` |
|
| 158 | + | 3. Decompress: `gunzip restore.sqlite.gz` |
|
| 159 | + | 4. Stop the target service so nothing is writing to the database. |
|
| 160 | + | 5. Copy into the volume: `docker run --rm -v jotts_jotts-data:/data -v $(pwd):/backup debian:bookworm-slim cp /backup/restore.sqlite /data/jotts.sqlite` |
|
| 161 | + | 6. Restart the service. |
|
| 150 | 162 | ||
| 151 | 163 | ## Configuration |
|
| 152 | 164 |
| 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, and optional <APP>_VOLUME overrides. |
|
| 17 | + | # |
|
| 18 | + | # Run this on the HOST. It shells out to `docker run` to write into volumes; |
|
| 19 | + | # the backup container itself mounts those volumes read-only. |
|
| 20 | + | ||
| 21 | + | BUCKET="${R2_BUCKET:-andromeda-backups}" |
|
| 22 | + | ||
| 23 | + | # app | volume-internal filename | volume env var | default volume name |
|
| 24 | + | APPS="jotts:jotts.sqlite:JOTTS_VOLUME:jotts_jotts-data |
|
| 25 | + | sipp:sipp.sqlite:SIPP_VOLUME:sipp_sipp-data |
|
| 26 | + | cellar:cellar.sqlite:CELLAR_VOLUME:cellar_cellar-data |
|
| 27 | + | posts:posts.sqlite:POSTS_VOLUME:posts_posts-data |
|
| 28 | + | feeds:feeds.sqlite:FEEDS_VOLUME:feeds_feeds-data |
|
| 29 | + | library:library.sqlite:LIBRARY_VOLUME:library_library-data |
|
| 30 | + | bookmarks:bookmarks.sqlite:BOOKMARKS_VOLUME:bookmarks_bookmarks-data |
|
| 31 | + | parcels:parcels.db:PARCELS_VOLUME:parcels_parcels_data |
|
| 32 | + | easel:easel.sqlite:EASEL_VOLUME:easel_easel-data" |
|
| 33 | + | ||
| 34 | + | RESTORE_IMAGE="debian:bookworm-slim" |
|
| 35 | + | ||
| 36 | + | usage() { |
|
| 37 | + | sed -n '6,19p' "$0" | sed 's/^# \{0,1\}//' |
|
| 38 | + | exit "${1:-0}" |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | die() { |
|
| 42 | + | echo "ERROR: $*" >&2 |
|
| 43 | + | exit 1 |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | # Look up a field for an app from the APPS registry. |
|
| 47 | + | # $1 app name, $2 field index (2=file, 3=env var, 4=default volume) |
|
| 48 | + | app_field() { |
|
| 49 | + | echo "$APPS" | while IFS=: read -r name file env def; do |
|
| 50 | + | [ "$name" = "$1" ] || continue |
|
| 51 | + | case "$2" in |
|
| 52 | + | 2) echo "$file" ;; |
|
| 53 | + | 3) echo "$env" ;; |
|
| 54 | + | 4) echo "$def" ;; |
|
| 55 | + | esac |
|
| 56 | + | break |
|
| 57 | + | done |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | app_exists() { |
|
| 61 | + | echo "$APPS" | cut -d: -f1 | grep -qx "$1" |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | # Resolve the Docker volume name for an app, honoring the <APP>_VOLUME override. |
|
| 65 | + | resolve_volume() { |
|
| 66 | + | app="$1" |
|
| 67 | + | env_var=$(app_field "$app" 3) |
|
| 68 | + | def=$(app_field "$app" 4) |
|
| 69 | + | # Indirect env lookup that works in POSIX sh. |
|
| 70 | + | val=$(eval "printf '%s' \"\${$env_var:-}\"") |
|
| 71 | + | if [ -n "$val" ]; then |
|
| 72 | + | echo "$val" |
|
| 73 | + | else |
|
| 74 | + | echo "$def" |
|
| 75 | + | fi |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | list_backups() { |
|
| 79 | + | app="$1" |
|
| 80 | + | echo "Backups for $app (s3://$BUCKET/$app/):" |
|
| 81 | + | aws s3 ls "s3://$BUCKET/$app/" --endpoint-url "$R2_ENDPOINT" \ |
|
| 82 | + | | awk '{print " " $1 " " $2 " " $4}' \ |
|
| 83 | + | || echo " (none or unreachable)" |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | # Echo the object key (filename only) to restore for an app. |
|
| 87 | + | resolve_key() { |
|
| 88 | + | app="$1" |
|
| 89 | + | if [ -n "$TIMESTAMP" ]; then |
|
| 90 | + | case "$TIMESTAMP" in |
|
| 91 | + | *.sqlite.gz) echo "$TIMESTAMP" ;; |
|
| 92 | + | *) echo "${TIMESTAMP}.sqlite.gz" ;; |
|
| 93 | + | esac |
|
| 94 | + | return |
|
| 95 | + | fi |
|
| 96 | + | aws s3 ls "s3://$BUCKET/$app/" --endpoint-url "$R2_ENDPOINT" \ |
|
| 97 | + | | awk '{print $4}' | grep -v '^$' | sort | tail -1 |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | restore_app() { |
|
| 101 | + | app="$1" |
|
| 102 | + | ||
| 103 | + | key=$(resolve_key "$app") |
|
| 104 | + | if [ -z "$key" ]; then |
|
| 105 | + | echo "WARN: no backups found for $app, skipping" |
|
| 106 | + | return 1 |
|
| 107 | + | fi |
|
| 108 | + | ||
| 109 | + | volume=$(resolve_volume "$app") |
|
| 110 | + | internal_file=$(app_field "$app" 2) |
|
| 111 | + | src="s3://$BUCKET/$app/$key" |
|
| 112 | + | ||
| 113 | + | if ! docker volume inspect "$volume" >/dev/null 2>&1; then |
|
| 114 | + | echo "WARN: docker volume '$volume' not found for $app, skipping" |
|
| 115 | + | return 1 |
|
| 116 | + | fi |
|
| 117 | + | ||
| 118 | + | if [ "$ASSUME_YES" != "1" ]; then |
|
| 119 | + | echo |
|
| 120 | + | echo "About to restore:" |
|
| 121 | + | echo " app: $app" |
|
| 122 | + | echo " source: $src" |
|
| 123 | + | echo " volume: $volume -> /$internal_file" |
|
| 124 | + | printf "Overwrite live data? [y/N] " |
|
| 125 | + | read -r answer |
|
| 126 | + | case "$answer" in |
|
| 127 | + | y|Y|yes|YES) ;; |
|
| 128 | + | *) echo "Skipped $app"; return 0 ;; |
|
| 129 | + | esac |
|
| 130 | + | fi |
|
| 131 | + | ||
| 132 | + | workdir=$(mktemp -d) |
|
| 133 | + | trap 'rm -rf "$workdir"' EXIT INT TERM |
|
| 134 | + | ||
| 135 | + | echo "$(date -u) Downloading $src ..." |
|
| 136 | + | aws s3 cp "$src" "$workdir/$key" --endpoint-url "$R2_ENDPOINT" \ |
|
| 137 | + | || { echo "WARN: download failed for $app"; rm -rf "$workdir"; trap - EXIT INT TERM; return 1; } |
|
| 138 | + | ||
| 139 | + | echo "$(date -u) Decompressing ..." |
|
| 140 | + | gunzip "$workdir/$key" |
|
| 141 | + | decompressed="$workdir/${key%.gz}" |
|
| 142 | + | ||
| 143 | + | echo "$(date -u) Verifying integrity ..." |
|
| 144 | + | check=$(sqlite3 "$decompressed" 'PRAGMA integrity_check;' 2>&1 || true) |
|
| 145 | + | if [ "$check" != "ok" ]; then |
|
| 146 | + | echo "WARN: integrity check failed for $app: $check" |
|
| 147 | + | rm -rf "$workdir"; trap - EXIT INT TERM |
|
| 148 | + | return 1 |
|
| 149 | + | fi |
|
| 150 | + | ||
| 151 | + | echo "$(date -u) Writing into volume $volume ..." |
|
| 152 | + | docker run --rm \ |
|
| 153 | + | -v "$volume:/data" \ |
|
| 154 | + | -v "$workdir:/backup:ro" \ |
|
| 155 | + | "$RESTORE_IMAGE" \ |
|
| 156 | + | cp "/backup/$(basename "$decompressed")" "/data/$internal_file" |
|
| 157 | + | ||
| 158 | + | rm -rf "$workdir" |
|
| 159 | + | trap - EXIT INT TERM |
|
| 160 | + | ||
| 161 | + | echo "$(date -u) OK: $app restored from $key" |
|
| 162 | + | echo "WARNING: stop the '$app' service before restoring and restart it after for a clean" |
|
| 163 | + | echo " restore. This script does not stop or start services." |
|
| 164 | + | return 0 |
|
| 165 | + | } |
|
| 166 | + | ||
| 167 | + | # --- arg parsing --------------------------------------------------------------- |
|
| 168 | + | ||
| 169 | + | [ $# -ge 1 ] || usage 1 |
|
| 170 | + | ||
| 171 | + | TARGET="" |
|
| 172 | + | TIMESTAMP="" |
|
| 173 | + | DO_LIST=0 |
|
| 174 | + | ASSUME_YES=0 |
|
| 175 | + | ||
| 176 | + | while [ $# -gt 0 ]; do |
|
| 177 | + | case "$1" in |
|
| 178 | + | -h|--help) usage 0 ;; |
|
| 179 | + | --list) DO_LIST=1 ;; |
|
| 180 | + | --yes|-y) ASSUME_YES=1 ;; |
|
| 181 | + | --timestamp) |
|
| 182 | + | shift |
|
| 183 | + | [ $# -ge 1 ] || die "--timestamp requires a value" |
|
| 184 | + | TIMESTAMP="$1" |
|
| 185 | + | ;; |
|
| 186 | + | --timestamp=*) TIMESTAMP="${1#*=}" ;; |
|
| 187 | + | -*) die "unknown option: $1" ;; |
|
| 188 | + | *) |
|
| 189 | + | [ -z "$TARGET" ] || die "unexpected argument: $1" |
|
| 190 | + | TARGET="$1" |
|
| 191 | + | ;; |
|
| 192 | + | esac |
|
| 193 | + | shift |
|
| 194 | + | done |
|
| 195 | + | ||
| 196 | + | [ -n "$TARGET" ] || usage 1 |
|
| 197 | + | : "${R2_ENDPOINT:?R2_ENDPOINT is required}" |
|
| 198 | + | ||
| 199 | + | if [ "$TARGET" = "all" ]; then |
|
| 200 | + | if [ -n "$TIMESTAMP" ] && [ "$DO_LIST" != "1" ]; then |
|
| 201 | + | die "--timestamp cannot be combined with 'all' (timestamps differ per app)" |
|
| 202 | + | fi |
|
| 203 | + | TARGETS=$(echo "$APPS" | cut -d: -f1) |
|
| 204 | + | else |
|
| 205 | + | app_exists "$TARGET" || die "unknown app: $TARGET (valid: $(echo "$APPS" | cut -d: -f1 | tr '\n' ' '))" |
|
| 206 | + | TARGETS="$TARGET" |
|
| 207 | + | fi |
|
| 208 | + | ||
| 209 | + | if [ "$DO_LIST" = "1" ]; then |
|
| 210 | + | for app in $TARGETS; do |
|
| 211 | + | list_backups "$app" |
|
| 212 | + | done |
|
| 213 | + | exit 0 |
|
| 214 | + | fi |
|
| 215 | + | ||
| 216 | + | failures=0 |
|
| 217 | + | total=0 |
|
| 218 | + | for app in $TARGETS; do |
|
| 219 | + | total=$((total + 1)) |
|
| 220 | + | restore_app "$app" || failures=$((failures + 1)) |
|
| 221 | + | done |
|
| 222 | + | ||
| 223 | + | if [ "$failures" -gt 0 ] && [ "$failures" -eq "$total" ]; then |
|
| 224 | + | die "all restores failed ($failures/$total)" |
|
| 225 | + | fi |
|
| 226 | + | echo "$(date -u) Restore complete ($((total - failures))/$total succeeded)" |