gendesign/tradein-mvp/deploy/backup-tradein-db.sh
bot-backend 83be088263 feat(ops): missed-run detection for backups + Forgejo code/DB backup to S3
Deliverable 1: ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh now
write a sentinel file on every verified-good run. A new ops/check-backup-
staleness.sh (separate cron entry, hourly) alerts via the existing Telegram
channel (same TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID idiom as ops/uptime-
healthcheck.sh, transition-tracked so it doesn't spam) if a sentinel goes
stale. Shared logic (notify/sentinel/state) factored into ops/lib-backup.sh
so it isn't triplicated; the two existing scripts' own hardening (integrity
checks, retention, etc.) is untouched.

Deliverable 2: ops/backup-forgejo.sh — Forgejo (git.gendsgn.ru) has no backup
today. Dumps the shared-postgres `forgejo` DB + tars the bare-repo tree, both
off-box to s3://gendsgn-backups/forgejo/ under a SEPARATE, narrower S3 key
(root-of-bucket writer key stays out of this). The key doesn't exist yet —
the script refuses to run and exits non-zero, loudly, until the four
FORGEJO_S3_* vars are filled in (see the example env file and PR description
for the exact bucket policy JSON to create it with).

Refs #2203, #2989
2026-08-21 15:37:19 +03:00

172 lines
9.3 KiB
Bash
Executable file
Raw Permalink 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
# Бэкап БД tradein-postgres — pg_dump по cron + retention (#397).
# Hardening для #2203: globals, integrity-check, S3-выгрузка.
#
# Запускается из cron на прод-хосте. pg_dump идёт через `docker exec`
# (локальный сокет внутри контейнера — пароль не нужен).
#
# Crontab (ежедневно в 04:30 по времени хоста = 01:30 UTC; на проде именно так, #3005).
# Вызывать через `bash <path>`, а не сырым путём — тогда потеря бита +x не сломает
# бэкап молча (инцидент #71 у основного бэкапа):
# 30 4 * * * bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1
#
# Restore:
# gunzip -c tradein-YYYYMMDD-HHMMSS.sql.gz | \
# docker exec -i tradein-postgres psql -U tradein -d tradein
# Restore-дрель (безопасно, не трогает прод): ops/restore-drill.sh.
#
# Опциональная выгрузка в S3 — переменные в /etc/default/tradein-backup
# (root-owned, chmod 600, НЕ в git); если файла нет — фолбэк на
# /etc/default/gendesign-backup (общие креды с основным бэкапом). Без
# переменных поведение прежнее: дамп остаётся только локально.
# S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
# S3_BUCKET=gendesign-backups
# S3_ACCESS_KEY=...
# S3_SECRET_KEY=...
#
# Детект пропущенного запуска (#2203): по успеху пишется sentinel-файл
# (SENTINEL_FILE) — отдельный cron-запуск ops/check-backup-staleness.sh
# следит за его возрастом и шлёт алерт, если он не свежел (см. заголовок
# того скрипта — канал алертов и cron-строка). Сам этот скрипт не алертит,
# только отмечает успех.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../ops/lib-backup.sh
source "$SCRIPT_DIR/../../ops/lib-backup.sh"
BACKUP_DIR="${BACKUP_DIR:-/opt/gendesign/backups/tradein}"
KEEP="${KEEP:-7}" # сколько копий хранить
PG_CONTAINER="${PG_CONTAINER:-tradein-postgres}"
MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-10240}" # 10 KiB floor — пустая/битая
# схема заведомо меньше, живая
# БД — на порядки больше.
SENTINEL_FILE="${SENTINEL_FILE:-${BACKUP_DIR}/.last_success}"
# S3-переменные: свой env-файл, а если его нет — общий с main-бэкапом.
if [[ -f /etc/default/tradein-backup ]]; then
source /etc/default/tradein-backup
elif [[ -f /etc/default/gendesign-backup ]]; then
source /etc/default/gendesign-backup
fi
# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск
# кончился, OOM-kill, оборвался docker exec) может дать структурно валидный,
# не крошечный .gz — ни `-s`, ни MIN_DUMP_BYTES это не ловят. Две проверки,
# сначала дешёвая:
# 1. gzip -t — ловит битый/оборванный gzip-поток как таковой.
# 2. trailer — pg_dump/pg_dumpall всегда пишут фиксированный комментарий
# «я дописал до конца» последней строкой потока; при обрыве записи
# трейлера не будет, даже если gzip-обёртка выглядит нормально.
# Проверяется через `gunzip -c | tail -N`, чтобы не распаковывать весь дамп
# на диск ради последних строк.
verify_dump_integrity() {
local file="$1" trailer="$2" label="$3"
if ! gzip -t "$file" 2>/dev/null; then
log "ОШИБКА: ${label} не прошёл проверку gzip -> $file" >&2
return 1
fi
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ОШИБКА: ${label} без трейлера '${trailer}' -> $file (дамп оборван?)" >&2
return 1
fi
return 0
}
mkdir -p "$BACKUP_DIR"
ts=$(date -u +'%Y%m%d-%H%M%S')
out="$BACKUP_DIR/tradein-$ts.sql.gz"
globals_out="$BACKUP_DIR/tradein-globals-$ts.sql.gz"
# --clean --if-exists → дамп самодостаточен для восстановления «с нуля».
# --no-owner → restore не требует тех же ролей.
# Stderr больше не глотается (было `2>/dev/null` до #2203) — ошибка pg_dump
# должна попасть в лог, а не исчезнуть молча. `set -o pipefail` уже роняет
# весь пайп при ошибке pg_dump, так что поведение при сбое не меняется —
# меняется только видимость причины.
docker exec "$PG_CONTAINER" pg_dump -U tradein -d tradein \
--no-owner --clean --if-exists | gzip -6 > "$out"
if [[ ! -s "$out" ]]; then
log "ОШИБКА: дамп пустой — $out" >&2
rm -f "$out"
exit 1
fi
dump_bytes=$(wc -c < "$out" | tr -d ' ')
if (( dump_bytes < MIN_DUMP_BYTES )); then
log "ОШИБКА: дамп всего ${dump_bytes} байт (< ${MIN_DUMP_BYTES} floor) — похоже на неудачный дамп." >&2
log "Удаляю подозрительный дамп, старые хорошие копии не трогаю." >&2
rm -f "$out"
exit 1
fi
if ! verify_dump_integrity "$out" "-- PostgreSQL database dump complete" "основной дамп"; then
log "Удаляю подозрительный дамп, старые хорошие копии не трогаю." >&2
rm -f "$out"
exit 1
fi
log "Дамп ok: $out ($(du -h "$out" | cut -f1), ${dump_bytes} байт)"
# --- globals (роли/GRANT) — pg_dump их не включает по определению; без
# этого восстановленная база остаётся без владеющих ролей/привилегий. ---
log "Дамплю globals (роли/GRANT) -> $globals_out"
docker exec "$PG_CONTAINER" pg_dumpall -U tradein --globals-only \
| gzip -6 > "$globals_out"
if [[ ! -s "$globals_out" ]]; then
log "ОШИБКА: дамп globals пустой — $globals_out" >&2
rm -f "$globals_out"
exit 1
fi
if ! verify_dump_integrity "$globals_out" "-- PostgreSQL database cluster dump complete" "globals-дамп"; then
rm -f "$globals_out"
exit 1
fi
log "Globals ok: $globals_out ($(du -h "$globals_out" | cut -f1))"
# --- опциональная выгрузка в S3 (только если заданы все четыре переменные) ---
if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -n "${S3_SECRET_KEY:-}" ]]; then
for f in "$out" "$globals_out"; do
log "Заливаю в s3://${S3_BUCKET}/$(basename "$f")"
# У aws-cli v2 свой CA-бандл (вшит в botocore), а не системное хранилище —
# в нём нет корня, которым подписан сертификат Selectel. Без переменной
# ниже выгрузка падает на CERTIFICATE_VERIFY_FAILED; системное хранилище
# контейнера этот корень содержит.
docker run --rm \
-e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
-e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
-v "$BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$S3_ENDPOINT" \
s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/"
done
log "Выгрузка в S3 ok"
else
log "S3-переменные не заданы — дамп остаётся только локально"
fi
# Retention — оставляем KEEP самых свежих, остальные удаляем. Основные дампы
# и globals-дампы — раздельные серии (иначе они смешаются в общей mtime-
# сортировке и retention посчитает их вместе).
ls -1t "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
ls -1t "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
size=$(du -h "$out" | cut -f1)
count=$(ls -1 "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
globals_count=$(ls -1 "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
log "backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"
# Детект пропущенного запуска (#2203): отмечаем успех только тут, после всех
# проверок выше (и — под `set -euo pipefail` — после S3-выгрузки, если она
# включена). См. ops/check-backup-staleness.sh.
write_sentinel "$SENTINEL_FILE"