scripts/backup.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
#
# Maestro backup: Postgres + MongoDB + Qdrant -> $BACKUP_DIR, with 7-daily /
# 4-weekly retention and an optional offsite push to any rclone remote
# (Oracle Object Storage via its S3-compatible endpoint in production).
#
# Designed for the production host (cron, see docs/DEPLOYMENT.md) but
# parameterized so the identical script can be tested against the local dev
# stack. Run it from the directory that contains the compose file.
#
# prod (defaults): ./backup.sh
# dev (from repo): COMPOSE_FILE=docker-compose.yml ENV_FILE= \
# BACKUP_DIR=./.backups bash scripts/backup.sh
#
# Failure model: every store is attempted even if an earlier one fails;
# failures are collected and reported at the end with a non-zero exit so
# cron surfaces them. Dumps are written to *.part and only mv'd into place
# on success — retention and the offsite push never see partial files.
#
# NOT backed up (deliberate): redis (ephemeral rate-limit buckets), ollama
# models (re-pulled by ollama-pull), caddy TLS certs (re-issued on demand),
# and .env.prod — that file must be backed up manually and encrypted, never
# by this script (it must not leave the host in plain text).
#
set -uo pipefail # no -e: one store failing must not skip the others
# --- Parameters (env-overridable; defaults are the production values) -------
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
ENV_FILE="${ENV_FILE-.env.prod}" # set ENV_FILE= (empty) for the dev stack
BACKUP_DIR="${BACKUP_DIR:-/opt/maestro/backups}"
# Compose network the one-shot curl container joins to reach Qdrant (which is
# distroless and publishes no ports). "maestro" is the compose project name in
# prod (`name:` in the compose file); override if COMPOSE_PROJECT_NAME differs.
DOCKER_NETWORK="${DOCKER_NETWORK:-maestro_default}"
# rclone remote for the offsite copy, e.g. "oci:maestro-backups".
# Empty = local-only (same env-gating pattern as EMAIL_PROVIDER/SENTRY_DSN).
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
CURL_IMAGE="curlimages/curl:8.11.1"
DAILY_KEEP_DAYS=7
WEEKLY_KEEP_DAYS=28
TS="$(date -u +%Y-%m-%dT%H-%M)"
FAILED=()
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$1"; }
fail() {
echo "ERROR: $1 backup failed" >&2
FAILED+=("$1")
}
if [ ! -f "$COMPOSE_FILE" ]; then
echo "ERROR: compose file '$COMPOSE_FILE' not found — run from the directory that contains it." >&2
exit 1
fi
COMPOSE=(docker compose -f "$COMPOSE_FILE")
[ -n "$ENV_FILE" ] && COMPOSE+=(--env-file "$ENV_FILE")
# Read one variable from $ENV_FILE. NEVER `source` the env file: values like
# EMAIL_FROM contain an unquoted '<' that bash would parse as a redirect.
get_env() {
[ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ] || return 0
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 | tr -d '\r'
}
mkdir -p "$BACKUP_DIR/daily" "$BACKUP_DIR/weekly"
# --- 1. Postgres (maestro DB) ------------------------------------------------
# In-container socket connections are trust-authenticated; no password needed.
# --clean --if-exists makes the restore a plain `psql <` drop-and-recreate.
log "postgres: dumping maestro"
f="$BACKUP_DIR/daily/maestro-pg-$TS.sql.gz"
if "${COMPOSE[@]}" exec -T postgres pg_dump -U maestro --clean --if-exists maestro |
gzip >"$f.part"; then
mv "$f.part" "$f"
else
rm -f "$f.part"
fail "postgres"
fi
# --- 2. Umami analytics DB (same instance; only when the profile is active) --
if get_env COMPOSE_PROFILES | grep -q analytics; then
log "postgres: dumping umami"
f="$BACKUP_DIR/daily/maestro-umami-$TS.sql.gz"
if "${COMPOSE[@]}" exec -T postgres pg_dump -U maestro --clean --if-exists umami |
gzip >"$f.part"; then
mv "$f.part" "$f"
else
rm -f "$f.part"
fail "umami"
fi
fi
# --- 3. MongoDB (maestro DB) ---------------------------------------------------
# Auth flags only when credentials are configured (the dev mongo is authless).
# The password is visible on the container's argv for the dump duration;
# acceptable on a single-operator host.
log "mongo: dumping maestro"
MONGO_ARGS=(--archive --gzip --db maestro)
MONGO_USER="$(get_env MONGO_USER)"
if [ -n "$MONGO_USER" ]; then
MONGO_ARGS+=(-u "$MONGO_USER" -p "$(get_env MONGO_PASSWORD)" --authenticationDatabase admin)
fi
f="$BACKUP_DIR/daily/maestro-mongo-$TS.archive.gz"
if "${COMPOSE[@]}" exec -T mongo mongodump "${MONGO_ARGS[@]}" >"$f.part"; then
mv "$f.part" "$f"
else
rm -f "$f.part"
fail "mongo"
fi
# --- 4. Qdrant (per-collection snapshots over HTTP) --------------------------
# The qdrant image is distroless (no shell) and publishes no ports in prod, so
# every API call runs in a throwaway curl container joined to the compose
# network. Snapshots are downloaded over HTTP and then DELETEd server-side so
# they never accumulate inside the qdrantdata volume. Per-collection snapshots
# (not the full-storage one) restore through the plain HTTP upload API while
# the stack is running. JSON is parsed with grep on purpose — no jq dependency
# on the host; the image is version-pinned so the response shape is stable.
QDRANT_KEY="$(get_env QDRANT_API_KEY)"
QCURL_HDR=()
[ -n "$QDRANT_KEY" ] && QCURL_HDR=(-H "api-key: $QDRANT_KEY")
qcurl() {
docker run --rm -i --network "$DOCKER_NETWORK" "$CURL_IMAGE" \
-fsS ${QCURL_HDR[@]+"${QCURL_HDR[@]}"} "$@"
}
json_names() { grep -o '"name":"[^"]*"' | cut -d'"' -f4; }
log "qdrant: listing collections"
if COLLECTIONS_JSON="$(qcurl http://qdrant:6333/collections)"; then
COLLECTIONS="$(printf '%s' "$COLLECTIONS_JSON" | json_names || true)"
for c in $COLLECTIONS; do
log "qdrant: snapshotting $c"
# Drop stale snapshots left behind by a previously crashed run.
for old in $(qcurl "http://qdrant:6333/collections/$c/snapshots" | json_names || true); do
qcurl -X DELETE "http://qdrant:6333/collections/$c/snapshots/$old?wait=true" >/dev/null || true
done
snap="$(qcurl -X POST "http://qdrant:6333/collections/$c/snapshots?wait=true" | json_names || true)"
if [ -z "$snap" ]; then
fail "qdrant-$c-create"
continue
fi
f="$BACKUP_DIR/daily/maestro-qdrant-$c-$TS.snapshot"
if qcurl "http://qdrant:6333/collections/$c/snapshots/$snap" >"$f.part"; then
mv "$f.part" "$f"
else
rm -f "$f.part"
fail "qdrant-$c-download"
fi
qcurl -X DELETE "http://qdrant:6333/collections/$c/snapshots/$snap?wait=true" >/dev/null ||
fail "qdrant-$c-cleanup"
done
else
fail "qdrant-list"
fi
# --- 5. Weekly copies (Sundays) ----------------------------------------------
if [ "$(date -u +%u)" = "7" ]; then
cp "$BACKUP_DIR"/daily/maestro-*"$TS"* "$BACKUP_DIR/weekly/" 2>/dev/null || true
fi
# --- 6. Local retention (scoped to our naming pattern — deletes nothing else)
find "$BACKUP_DIR/daily" -name 'maestro-*' -type f -mtime +"$DAILY_KEEP_DAYS" -delete
find "$BACKUP_DIR/weekly" -name 'maestro-*' -type f -mtime +"$WEEKLY_KEEP_DAYS" -delete
# --- 7. Offsite push (env-gated) ----------------------------------------------
# `copy` (additive), deliberately NOT `sync`: syncing from a freshly rebuilt
# host with an empty backups dir would wipe every offsite backup — exactly the
# disaster the offsite copy exists to survive. Remote retention is pruned
# separately with --min-age. Only $BACKUP_DIR is pushed; .env.prod never
# enters it, so secrets structurally cannot leave the host through this path.
if [ -n "$RCLONE_REMOTE" ]; then
log "offsite: rclone copy to $RCLONE_REMOTE"
rclone copy "$BACKUP_DIR/daily" "$RCLONE_REMOTE/daily" --include 'maestro-*' || fail "rclone-daily"
rclone copy "$BACKUP_DIR/weekly" "$RCLONE_REMOTE/weekly" --include 'maestro-*' || fail "rclone-weekly"
rclone delete "$RCLONE_REMOTE/daily" --min-age "$((DAILY_KEEP_DAYS + 1))d" --include 'maestro-*' || fail "rclone-prune-daily"
rclone delete "$RCLONE_REMOTE/weekly" --min-age "$((WEEKLY_KEEP_DAYS + 1))d" --include 'maestro-*' || fail "rclone-prune-weekly"
else
log "offsite: skipped (RCLONE_REMOTE not set)"
fi
# --- 8. Summary ---------------------------------------------------------------
if [ ${#FAILED[@]} -gt 0 ]; then
echo "BACKUP FAILED: ${FAILED[*]}" >&2
exit 1
fi
log "backup ok: $TS ($(du -sh "$BACKUP_DIR/daily" | cut -f1) local)"