briven-backup.sh176 lines · main
| 1 | #!/usr/bin/env bash |
| 2 | # Daily backup of Briven — Doltgres-native (PRIMARY DR) + stock-Postgres pg_dump |
| 3 | # (secondary rollback). Runs on the France host via systemd |
| 4 | # (briven-backup.{service,timer}). |
| 5 | # |
| 6 | # 2026-08-01 REWORK — why this changed: |
| 7 | # The old `dolt-backup` SIDECAR looped `dolt_backup('sync-url', …)` over EVERY |
| 8 | # database with no throttle, against a buggy DoltGres 0.56.6. That contributed |
| 9 | # to a full-platform outage (engine locked under load). Fix: engine upgraded to |
| 10 | # 0.57.2 (lock-subsystem fixes) with auto-GC disabled, and backups are now a |
| 11 | # GENTLE, one-database-at-a-time, throttled pass run from this host job. The |
| 12 | # sidecar is removed from compose. Proven safe under monitoring (2026-08-01). |
| 13 | # |
| 14 | # Backup layers: |
| 15 | # 1. PRIMARY — `dolt_backup('sync-url', file:///backups/<db>)` for ALL live |
| 16 | # DoltGres DBs (control + engine + every project) → doltgres_backups |
| 17 | # volume (/backups). Restorable via `dolt backup restore`. |
| 18 | # 2. auth.db — snapshot the engine users/grants file (nothing else backs it up). |
| 19 | # 3. SECONDARY— pg_dump of stock-Postgres briven_control (rollback window helper). |
| 20 | # 4. OFF-SITE — mirror the doltgres_backups volume to external S3 (Backblaze/R2/…) |
| 21 | # when BRIVEN_BACKUP_S3_* is configured (see BACKUP-OFFSITE.md). |
| 22 | # |
| 23 | # Env (/etc/briven/backup.env, optional): |
| 24 | # BRIVEN_DOLTGRES_CONTAINER default: briven-brivenfrance-uilsk6-doltgres-1 |
| 25 | # BRIVEN_DOLTGRES_BACKUPS_VOLUME default: briven-brivenfrance-uilsk6_doltgres_backups |
| 26 | # BRIVEN_DOLTGRES_PASSWORD default: read from the doltgres container env |
| 27 | # BRIVEN_BACKUP_THROTTLE_SECS default: 8 (pause between DBs — gentleness) |
| 28 | # BRIVEN_BACKUP_PG_CONTAINER default: briven-brivenfrance-uilsk6-postgres-1 |
| 29 | # BRIVEN_BACKUP_PG_USER default: postgres |
| 30 | # BRIVEN_BACKUP_PG_DBS default: briven_control (secondary pg_dump) |
| 31 | # BRIVEN_BACKUP_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY → off-site mirror |
| 32 | # BRIVEN_BACKUP_LOCAL_RETENTION_DAYS default: 30 |
| 33 | # |
| 34 | # Exit 1 if the dolt phase fails for any DB OR an off-site mirror fails, so |
| 35 | # OnFailure=briven-backup-alert.service fires. |
| 36 | |
| 37 | set -euo pipefail |
| 38 | |
| 39 | BACKUP_ENV_FILE="/etc/briven/backup.env" |
| 40 | if [ -f "$BACKUP_ENV_FILE" ]; then |
| 41 | # shellcheck disable=SC1090 |
| 42 | source "$BACKUP_ENV_FILE" |
| 43 | fi |
| 44 | |
| 45 | DC="${BRIVEN_DOLTGRES_CONTAINER:-briven-brivenfrance-uilsk6-doltgres-1}" |
| 46 | BACKUPS_VOLUME="${BRIVEN_DOLTGRES_BACKUPS_VOLUME:-briven-brivenfrance-uilsk6_doltgres_backups}" |
| 47 | THROTTLE="${BRIVEN_BACKUP_THROTTLE_SECS:-8}" |
| 48 | PG_CONTAINER="${BRIVEN_BACKUP_PG_CONTAINER:-briven-brivenfrance-uilsk6-postgres-1}" |
| 49 | PG_USER="${BRIVEN_BACKUP_PG_USER:-postgres}" |
| 50 | # shellcheck disable=SC2206 |
| 51 | PG_DBS=(${BRIVEN_BACKUP_PG_DBS:-briven_control}) |
| 52 | LOCAL_BACKUP_ROOT="/var/backups/briven" |
| 53 | LOCAL_RETENTION_DAYS="${BRIVEN_BACKUP_LOCAL_RETENTION_DAYS:-30}" |
| 54 | |
| 55 | STAMP="$(date -u +'%Y-%m-%d/%H-%M-%S')" |
| 56 | STAMP_FLAT="$(date -u +'%Y-%m-%dT%H-%M-%SZ')" |
| 57 | FAILURES=0 |
| 58 | FAILURE_DETAIL="" |
| 59 | |
| 60 | log() { printf '[%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; } |
| 61 | die() { log "ERROR: $*"; exit 1; } |
| 62 | fail() { log "WARN: $*"; FAILURES=$((FAILURES + 1)); FAILURE_DETAIL="${FAILURE_DETAIL:+${FAILURE_DETAIL}; }$*"; } |
| 63 | |
| 64 | # --- resolve doltgres password without printing it --- |
| 65 | dolt_password() { |
| 66 | if [ -n "${BRIVEN_DOLTGRES_PASSWORD:-}" ]; then |
| 67 | printf '%s' "$BRIVEN_DOLTGRES_PASSWORD"; return 0 |
| 68 | fi |
| 69 | docker inspect "$DC" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \ |
| 70 | | sed -n 's/^DOLTGRES_PASSWORD=//p' | head -1 |
| 71 | } |
| 72 | |
| 73 | # ===== PHASE 1: PRIMARY — gentle DoltGres-native backup of every live DB ===== |
| 74 | dolt_backup_all() { |
| 75 | docker inspect "$DC" >/dev/null 2>&1 || die "doltgres container not found: ${DC}" |
| 76 | local pw; pw="$(dolt_password)" |
| 77 | [ -n "$pw" ] || die "could not resolve DOLTGRES_PASSWORD" |
| 78 | local base="postgres://postgres:${pw}@127.0.0.1:5432" |
| 79 | |
| 80 | log "dolt phase: enumerating live databases" |
| 81 | local dbs ok=0 |
| 82 | dbs="$(docker exec "$DC" sh -lc \ |
| 83 | "psql \"${base}/postgres?sslmode=disable\" -tAc \"select datname from pg_database where datname not in ('template0','template1','postgres')\"" \ |
| 84 | 2>/dev/null || true)" |
| 85 | [ -n "$dbs" ] || die "no databases enumerated (is doltgres serving?)" |
| 86 | |
| 87 | for db in $dbs; do |
| 88 | # sync-url writes a restorable dolt archive to the server's /backups/<db>. |
| 89 | if docker exec "$DC" sh -lc \ |
| 90 | "psql \"${base}/${db}?sslmode=disable\" -tAc \"select dolt_backup('sync-url','file:///backups/${db}')\"" \ |
| 91 | >/dev/null 2>&1; then |
| 92 | log " ok dolt backup: ${db}" |
| 93 | ok=$((ok + 1)) |
| 94 | else |
| 95 | fail "dolt backup failed: ${db}" |
| 96 | fi |
| 97 | sleep "$THROTTLE" # gentleness — never hammer the engine |
| 98 | done |
| 99 | log "dolt phase: ${ok} database(s) backed up (throttle=${THROTTLE}s)" |
| 100 | |
| 101 | # auth.db snapshot — engine users/grants; keep newest 14. |
| 102 | if docker exec "$DC" test -f /var/lib/doltgres/auth.db 2>/dev/null; then |
| 103 | if docker exec "$DC" sh -lc \ |
| 104 | "mkdir -p /backups/auth-db && cp /var/lib/doltgres/auth.db /backups/auth-db/auth.db.${STAMP_FLAT} && ls -1t /backups/auth-db | tail -n +15 | while read -r f; do rm -f \"/backups/auth-db/\$f\"; done" \ |
| 105 | >/dev/null 2>&1; then |
| 106 | log " ok auth.db snapshot" |
| 107 | else |
| 108 | fail "auth.db snapshot failed" |
| 109 | fi |
| 110 | fi |
| 111 | } |
| 112 | |
| 113 | # ===== PHASE 2: SECONDARY — pg_dump of stock-Postgres (rollback window) ===== |
| 114 | pg_dump_secondary() { |
| 115 | docker inspect "$PG_CONTAINER" >/dev/null 2>&1 || { log "pg secondary: container ${PG_CONTAINER} absent — skipping"; return 0; } |
| 116 | for db in "${PG_DBS[@]}"; do |
| 117 | local dir="${LOCAL_BACKUP_ROOT}/${db}/${STAMP%/*}" |
| 118 | local file="${dir}/${STAMP##*/}.dump.gz" |
| 119 | mkdir -p "$dir" |
| 120 | if docker exec "$PG_CONTAINER" pg_dump --username="$PG_USER" --format=custom --compress=0 \ |
| 121 | --no-owner --no-privileges "$db" 2>/dev/null | gzip -9 > "$file"; then |
| 122 | log " ok pg_dump ${db}: $(stat -c%s "$file") bytes" |
| 123 | else |
| 124 | rm -f "$file"; fail "pg_dump failed: ${db}" |
| 125 | fi |
| 126 | done |
| 127 | } |
| 128 | |
| 129 | # ===== PHASE 3: OFF-SITE — mirror the dolt backups volume to external S3 ===== |
| 130 | offsite_mirror() { |
| 131 | if [ -z "${BRIVEN_BACKUP_S3_ENDPOINT:-}" ] || [ -z "${BRIVEN_BACKUP_S3_BUCKET:-}" ] \ |
| 132 | || [ -z "${BRIVEN_BACKUP_S3_ACCESS_KEY:-}" ] || [ -z "${BRIVEN_BACKUP_S3_SECRET_KEY:-}" ]; then |
| 133 | log "off-site mirror skipped (BRIVEN_BACKUP_S3_* unset) — see BACKUP-OFFSITE.md" |
| 134 | return 0 |
| 135 | fi |
| 136 | local ep="${BRIVEN_BACKUP_S3_ENDPOINT#https://}"; ep="${ep#http://}" |
| 137 | local vol="/var/lib/docker/volumes/${BACKUPS_VOLUME}/_data" |
| 138 | [ -d "$vol" ] || { fail "off-site: backups volume path missing: ${vol}"; return 0; } |
| 139 | log "off-site mirror → s3://${BRIVEN_BACKUP_S3_BUCKET}/doltgres-backups/" |
| 140 | if docker run --rm -v "${vol}:/backups:ro" \ |
| 141 | -e "MC_HOST_off=https://${BRIVEN_BACKUP_S3_ACCESS_KEY}:${BRIVEN_BACKUP_S3_SECRET_KEY}@${ep}" \ |
| 142 | --entrypoint sh minio/mc:latest \ |
| 143 | -c "mc mirror --overwrite --remove /backups off/${BRIVEN_BACKUP_S3_BUCKET}/doltgres-backups/" >/dev/null 2>&1; then |
| 144 | log "off-site mirror ok" |
| 145 | else |
| 146 | fail "off-site mirror failed" |
| 147 | fi |
| 148 | if [ -d "$LOCAL_BACKUP_ROOT" ]; then |
| 149 | docker run --rm -v "${LOCAL_BACKUP_ROOT}:/pgd:ro" \ |
| 150 | -e "MC_HOST_off=https://${BRIVEN_BACKUP_S3_ACCESS_KEY}:${BRIVEN_BACKUP_S3_SECRET_KEY}@${ep}" \ |
| 151 | --entrypoint sh minio/mc:latest \ |
| 152 | -c "mc mirror --overwrite /pgd off/${BRIVEN_BACKUP_S3_BUCKET}/pg-dumps/" >/dev/null 2>&1 \ |
| 153 | || fail "off-site mirror (pg dumps) failed" |
| 154 | fi |
| 155 | } |
| 156 | |
| 157 | prune_local() { |
| 158 | [ -d "$LOCAL_BACKUP_ROOT" ] || return 0 |
| 159 | log "pruning local pg dumps older than ${LOCAL_RETENTION_DAYS}d" |
| 160 | find "$LOCAL_BACKUP_ROOT" -type f -name '*.dump.gz' -mtime +"$LOCAL_RETENTION_DAYS" -delete || true |
| 161 | find "$LOCAL_BACKUP_ROOT" -type d -empty -delete || true |
| 162 | } |
| 163 | |
| 164 | log "briven backup run starting (doltgres=${DC}, throttle=${THROTTLE}s)" |
| 165 | dolt_backup_all # primary DR |
| 166 | pg_dump_secondary # secondary rollback |
| 167 | offsite_mirror # off-site (if configured) |
| 168 | prune_local |
| 169 | |
| 170 | if [ "$FAILURES" -gt 0 ]; then |
| 171 | log "ERROR: ${FAILURES} failure(s): ${FAILURE_DETAIL}" |
| 172 | { echo "failures=${FAILURES}"; echo "detail=${FAILURE_DETAIL}"; } > /run/briven-backup-status |
| 173 | exit 1 |
| 174 | fi |
| 175 | rm -f /run/briven-backup-status |
| 176 | log "briven backup run complete — all layers ok" |