printhive/tools/backup.sh
dlawler489 9e92be8407 Pin the data paths in compose, and find the files via the container
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>
2026-09-20 16:59:13 +10:00

103 lines
4.5 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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))"