diff --git a/deploy/backup/fs-backup.sh b/deploy/backup/fs-backup.sh index 79c54c5..0757614 100644 --- a/deploy/backup/fs-backup.sh +++ b/deploy/backup/fs-backup.sh @@ -166,14 +166,33 @@ backup_to() { # backup_to [--tag …] } # Именованные снимки (run --tag …, метка keep) и страховочные (pre-restore) политика не -# удаляет — только вручную: fs-backup restic forget --prune. +# удаляет — только вручную: fs-backup restic forget --prune. Три последних +# снимка остаются всегда (keep-daily иначе заменил бы более ранний снимок того же дня). forget_repo() { - log "Очистка по политике: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. (+ все keep и pre-restore)…" - r "$1" forget --host "$SNAP_HOST" --group-by host \ + log "Очистка по политике: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. (+ 3 последних, keep и pre-restore)…" + r "$1" forget --host "$SNAP_HOST" --group-by host --keep-last 3 \ --keep-daily "$KEEP_DAILY" --keep-weekly "$KEEP_WEEKLY" --keep-monthly "$KEEP_MONTHLY" \ --keep-tag keep --keep-tag pre-restore --prune >&2 } +# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не +# должны становиться «последним снимком» (restore latest вернул бы пустоту). +guard_empty() { # guard_empty <игроков> <партий> + if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi + for _repo in $(repos); do + _prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \ + '[.[0].tags[]? | select(startswith("players:") or startswith("matches:")) + | ltrimstr("players:") | ltrimstr("matches:") | (tonumber? // 0)] | add // 0')" || _prev=0 + if [ "${_prev:-0}" -gt 0 ]; then + for _r in $(repos); do mark "run-$_r" err "БД пуста, а в репозитории есть данные — бэкап не сделан (см. README, «Катастрофа»)"; done + log "В БД нет ни игроков, ни партий, а последний снимок в репозитории $_repo — с данными." + log "Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю." + log "Восстановите данные (deploy/backup/README.md, «Катастрофа»). Если БД очищена намеренно:" + die "fs-backup run --allow-empty" + fi + done +} + latest_short_id() { r "$1" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r '.[0].short_id // empty' } @@ -202,9 +221,11 @@ warn_leftovers() { cmd_run() { _kind=manual _tag="" + _allow_empty=no while [ $# -gt 0 ]; do case "$1" in --scheduled) _kind=scheduled ;; + --allow-empty) _allow_empty=yes ;; --tag) [ $# -ge 2 ] || die "--tag требует значение" shift @@ -229,6 +250,7 @@ cmd_run() { _players="${_counts%%|*}" _matches="${_counts##*|}" log "БД в порядке: игроков $_players, партий $_matches." + if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi _failed="" for _repo in $(repos); do @@ -297,7 +319,7 @@ cmd_status() { echo " Расписание бэкапа: ${BACKUP_SCHEDULE:-отключено}" echo " Расписание проверки: ${BACKUP_VERIFY_SCHEDULE:-отключено}" echo " Часовой пояс: ${TZ:-UTC}" - echo " Хранение: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. + именованные (keep) и pre-restore" + echo " Хранение: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. + 3 последних, именованные (keep) и pre-restore" echo " Сжатие restic: $RESTIC_COMPRESSION" echo " Имя хоста в снимках: $SNAP_HOST" vps_enabled || echo " VPS: не настроен (только локальная копия)" @@ -765,8 +787,10 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из Повседневное: status состояние: последние бэкапы/проверки, размеры репозиториев list [local|vps] хронология снимков (время, игроков, партий, размер, прирост) - run [--tag имя] сделать снимок сейчас; с --tag снимок именованный и очисткой - не удаляется (например, --tag before-update) + run [--tag имя] [--allow-empty] + сделать снимок сейчас; с --tag снимок именованный и очисткой + не удаляется (например, --tag before-update); --allow-empty — + разрешить снимок пустой БД, когда в истории есть данные verify проверить целостность данных в репозиториях Восстановление (сначала: docker compose stop app; после: docker compose start app): diff --git a/scripts/backup.sh b/scripts/backup.sh deleted file mode 100755 index ce4db80..0000000 --- a/scripts/backup.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# Полный консистентный онлайн-бэкап прода (БД + uploads + achievements) с работающего -# контейнера + оффсайт-копия на VPS. Запуск по cron на хосте Pi, например ежедневно в 04:00: -# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1 -# -# Настройки берутся из корневого .env (BACKUP_*), их можно переопределить переменными -# окружения. Если BACKUP_VPS_HOST пуст — делается только локальная копия в backups/. -set -euo pipefail - -PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml" -SERVICE="app" -TS="$(date +%Y%m%d_%H%M%S)" - -# Безопасно прочитать ОДИН ключ из .env (НЕ source: в .env есть спецсимволы # & и т.п.). -read_env() { # $1=ключ $2=дефолт - local val - val="$(grep -E "^$1=" "$PROJECT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r')" - val="${val%\"}"; val="${val#\"}" # снять обрамляющие кавычки, если есть - printf '%s' "${val:-$2}" -} - -BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}" -KEEP_LOCAL="${BACKUP_KEEP_LOCAL:-$(read_env BACKUP_KEEP_LOCAL 14)}" -KEEP_REMOTE="${BACKUP_KEEP_REMOTE:-$(read_env BACKUP_KEEP_REMOTE 30)}" -VPS_HOST="${BACKUP_VPS_HOST:-$(read_env BACKUP_VPS_HOST "")}" -VPS_USER="${BACKUP_VPS_USER:-$(read_env BACKUP_VPS_USER backup)}" -VPS_DIR="${BACKUP_VPS_DIR:-$(read_env BACKUP_VPS_DIR /srv/fs-backups)}" -VPS_KEY="${BACKUP_VPS_KEY:-$(read_env BACKUP_VPS_KEY "$PROJECT_DIR/deploy/backup/id_backup")}" - -mkdir -p "$BACKUP_DIR" -STAGE="$(mktemp -d)" -trap 'rm -rf "$STAGE"' EXIT - -CID="$($COMPOSE ps -q "$SERVICE")" -if [ -z "$CID" ]; then - echo "[backup] ОШИБКА: контейнер '$SERVICE' не запущен." >&2 - exit 1 -fi - -echo "[backup] Консистентный снимок БД…" -$COMPOSE exec -T "$SERVICE" python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()" -docker cp "$CID:/data/backup_tmp.db" "$STAGE/forbidden_stars.db" -$COMPOSE exec -T "$SERVICE" rm -f /data/backup_tmp.db - -echo "[backup] Копирование медиа и титулов…" -docker cp "$CID:/data/uploads" "$STAGE/uploads" -docker cp "$CID:/data/achievements" "$STAGE/achievements" - -ARCHIVE="$BACKUP_DIR/fs_$TS.tar.gz" -tar -czf "$ARCHIVE" -C "$STAGE" forbidden_stars.db uploads achievements -echo "[backup] Локальный архив: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))" - -# Локальная ротация: оставить последние $KEEP_LOCAL (архив только что создан → ≥1 файл). -ls -1t "$BACKUP_DIR"/fs_*.tar.gz 2>/dev/null | tail -n +"$((KEEP_LOCAL + 1))" | xargs -r rm -f - -# Оффсайт-копия на VPS (push: Pi за CGNAT, входящих нет). -if [ -n "$VPS_HOST" ]; then - if [ ! -f "$VPS_KEY" ]; then - echo "[backup] ОШИБКА: ключ $VPS_KEY не найден — оффсайт-копия пропущена." >&2 - exit 1 - fi - echo "[backup] Отправка на VPS $VPS_USER@$VPS_HOST:$VPS_DIR …" - SSH_OPTS=(-i "$VPS_KEY" -o StrictHostKeyChecking=accept-new -o BatchMode=yes) - scp "${SSH_OPTS[@]}" "$ARCHIVE" "$VPS_USER@$VPS_HOST:$VPS_DIR/" - # Удалённая ротация (sh на VPS без pipefail → пустой ls не уронит команду). - ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" \ - "ls -1t '$VPS_DIR'/fs_*.tar.gz 2>/dev/null | tail -n +$((KEEP_REMOTE + 1)) | xargs -r rm -f" - echo "[backup] Оффсайт-копия отправлена." -else - echo "[backup] BACKUP_VPS_HOST не задан — только локальная копия." -fi - -echo "[backup] Готово." diff --git a/scripts/restore.sh b/scripts/restore.sh deleted file mode 100755 index 3057571..0000000 --- a/scripts/restore.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# Восстановление прода из архива бэкапа (fs_.tar.gz): БД + uploads + achievements. -# ДЕСТРУКТИВНО — заменяет текущие данные тома. Сначала проверьте на тест-контуре! -# Использование: -# scripts/restore.sh backups/fs_YYYYmmdd_HHMMSS.tar.gz [--yes] -set -euo pipefail - -PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml" -SERVICE="app" - -ARCHIVE="${1:-}" -CONFIRM="${2:-}" -if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then - echo "Использование: $0 <путь к fs_*.tar.gz> [--yes]" >&2 - exit 1 -fi -if [ "$CONFIRM" != "--yes" ]; then - printf "ВНИМАНИЕ: текущие БД/uploads/achievements будут заменены из %s\nПродолжить? [y/N] " "$ARCHIVE" - read -r ans - case "$ans" in y|Y|yes|да|Да) ;; *) echo "Отменено."; exit 1 ;; esac -fi - -STAGE="$(mktemp -d)" -trap 'rm -rf "$STAGE"' EXIT -tar -xzf "$ARCHIVE" -C "$STAGE" -if [ ! -f "$STAGE/forbidden_stars.db" ]; then - echo "ОШИБКА: в архиве нет forbidden_stars.db." >&2 - exit 1 -fi - -echo "[restore] Остановка приложения…" -$COMPOSE stop "$SERVICE" - -echo "[restore] Очистка тома (старая БД, WAL/SHM, медиа, титулы)…" -$COMPOSE run --rm --no-deps -T --entrypoint sh "$SERVICE" -c \ - "rm -f /data/forbidden_stars.db /data/forbidden_stars.db-wal /data/forbidden_stars.db-shm; rm -rf /data/uploads/* /data/achievements/*" - -CID="$($COMPOSE ps -aq "$SERVICE")" -echo "[restore] Запись данных в контейнер…" -docker cp "$STAGE/forbidden_stars.db" "$CID:/data/forbidden_stars.db" -[ -d "$STAGE/uploads" ] && docker cp "$STAGE/uploads/." "$CID:/data/uploads" -[ -d "$STAGE/achievements" ] && docker cp "$STAGE/achievements/." "$CID:/data/achievements" - -echo "[restore] Запуск приложения…" -$COMPOSE start "$SERVICE" -echo "[restore] Готово. Проверьте https://forbiddenstars.ru и логи: $COMPOSE logs -f app"