A stack manager that deploys from its own directory resolved ./data to an empty folder, so Postgres initialised a blank database and the install looked wiped. The compose defaults are absolute now, and survive a sync that deletes an untracked .env. The backup script asks the running container where /app/uploads comes from rather than trusting a setting, refuses to mirror an empty folder, and won't write a dump of an empty library over backups that have one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
4.5 KiB
Bash
Executable file
103 lines
4.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# Nightly backup: a dated dump of the database, and a mirror of the model files.
|
||
#
|
||
# Run it from anywhere — it finds the folder holding docker-compose.yml itself.
|
||
# Printhive keeps running throughout; pg_dump takes a consistent snapshot.
|
||
#
|
||
# ./tools/backup.sh
|
||
#
|
||
# Settings (in .env beside docker-compose.yml, or the environment):
|
||
# BACKUP_DIR where backups go (default: ./backups)
|
||
# UPLOADS_DIR the model files (default: asked of the running container)
|
||
# KEEP_DAYS dumps to keep, in days (default: 14)
|
||
set -euo pipefail
|
||
|
||
cd "$(dirname "$0")/.."
|
||
|
||
# A setting from the environment, else from .env, else the fallback. Tolerates
|
||
# "export NAME=", quotes, spaces and Windows line endings.
|
||
setting() { # name, fallback
|
||
local from_env="${!1:-}"
|
||
if [ -n "$from_env" ]; then echo "$from_env"; return; fi
|
||
if [ -f .env ]; then
|
||
local line
|
||
line="$(tr -d '\r' < .env \
|
||
| grep -E "^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=" \
|
||
| tail -1 | cut -d= -f2- \
|
||
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/")"
|
||
if [ -n "$line" ]; then echo "$line"; return; fi
|
||
fi
|
||
echo "$2"
|
||
}
|
||
|
||
BACKUP_DIR="$(setting BACKUP_DIR ./backups)"
|
||
KEEP_DAYS="$(setting KEEP_DAYS 14)"
|
||
stamp="$(date +%Y-%m-%d)"
|
||
|
||
# Where the model files really are: ask the running container, rather than
|
||
# trusting a setting that may have drifted from how the stack was deployed.
|
||
UPLOADS_DIR="${UPLOADS_DIR:-}"
|
||
if [ -z "$UPLOADS_DIR" ]; then
|
||
UPLOADS_DIR="$(docker inspect printhive-app \
|
||
--format '{{range .Mounts}}{{if eq .Destination "/app/uploads"}}{{.Source}}{{end}}{{end}}' 2>/dev/null || true)"
|
||
fi
|
||
if [ -z "$UPLOADS_DIR" ]; then
|
||
echo "$(date '+%F %T') FAILED: couldn’t find where /app/uploads comes from — is printhive-app running?" >&2
|
||
exit 1
|
||
fi
|
||
|
||
echo "$(date '+%F %T') files: $UPLOADS_DIR → backups: $BACKUP_DIR"
|
||
|
||
# A path the container can see but this machine can't (a stack manager deploying
|
||
# from inside a container does that) would back up nothing at all.
|
||
if [ ! -d "$UPLOADS_DIR" ]; then
|
||
echo "$(date '+%F %T') FAILED: $UPLOADS_DIR isn’t reachable from here — set UPLOADS_DIR to its path on this machine" >&2
|
||
exit 1
|
||
fi
|
||
if [ -z "$(ls -A "$UPLOADS_DIR" 2>/dev/null)" ]; then
|
||
echo "$(date '+%F %T') FAILED: $UPLOADS_DIR is empty — refusing to mirror nothing over a good backup" >&2
|
||
exit 1
|
||
fi
|
||
|
||
mkdir -p "$BACKUP_DIR/db"
|
||
|
||
# --- the database -----------------------------------------------------------
|
||
# Written to a .part file first, so an interrupted run never leaves behind
|
||
# something that looks like a good backup.
|
||
# An empty library where backups already exist means something is wrong with
|
||
# the stack, not that you deleted everything: don't write that over good ones.
|
||
models="$(docker compose exec -T db psql -U printhive -d printhive -tAc 'select count(*) from models' 2>/dev/null | tr -d '[:space:]' || true)"
|
||
if [ "$models" = "0" ] && ls "$BACKUP_DIR"/db/printhive-*.dump >/dev/null 2>&1; then
|
||
echo "$(date '+%F %T') FAILED: the database has no models, but older backups do — leaving them alone" >&2
|
||
exit 1
|
||
fi
|
||
|
||
dump="$BACKUP_DIR/db/printhive-$stamp.dump"
|
||
|
||
# Dumped inside the container and read back there — a dump that can't be listed
|
||
# is no backup at all, and pg_restore can only list a real file, not a pipe.
|
||
if ! docker compose exec -T db sh -c \
|
||
'pg_dump -U printhive -Fc printhive > /tmp/printhive.dump && pg_restore -l /tmp/printhive.dump > /dev/null'; then
|
||
echo "$(date '+%F %T') FAILED: couldn’t dump the database — keeping the old backups" >&2
|
||
docker compose exec -T db rm -f /tmp/printhive.dump || true
|
||
exit 1
|
||
fi
|
||
docker compose exec -T db cat /tmp/printhive.dump > "$dump.part"
|
||
docker compose exec -T db rm -f /tmp/printhive.dump || true
|
||
|
||
if [ ! -s "$dump.part" ]; then
|
||
echo "$(date '+%F %T') FAILED: the dump came out empty — keeping the old backups" >&2
|
||
rm -f "$dump.part"
|
||
exit 1
|
||
fi
|
||
mv "$dump.part" "$dump"
|
||
echo "$(date '+%F %T') database → $dump ($(du -h "$dump" | cut -f1))"
|
||
|
||
# Older dumps go, so the folder doesn't grow for ever.
|
||
find "$BACKUP_DIR/db" -name 'printhive-*.dump' -type f -mtime "+$KEEP_DAYS" -delete
|
||
|
||
# --- the model files --------------------------------------------------------
|
||
# A mirror, not a history: it matches what's there now, deletions included.
|
||
# Thumbnails, previews and extracted archives are left out — they rebuild.
|
||
rsync -a --delete "$UPLOADS_DIR/" "$BACKUP_DIR/uploads/"
|
||
echo "$(date '+%F %T') files → $BACKUP_DIR/uploads ($(du -sh "$BACKUP_DIR/uploads" | cut -f1))"
|