#!/bin/sh # fs-backup — бэкапы Forbidden Stars на restic (работает внутри контейнера backup). # # docker compose exec backup fs-backup help # # Раскладка снимка: /fs/forbidden_stars.db (консистентная копия БД), /fs/uploads/, # /fs/achievements/ — та же, что у архивов fs_*.tar.gz старого scripts/backup.sh. # Репозитории: local — том backup-data (/backup/repo), vps — SFTP на VPS (если задан). # Подробная инструкция: deploy/backup/README.md. set -eu # ─── Пути ───────────────────────────────────────────────────────────────────── DB_VOLUME=/fs-db # том db-data: живая БД приложения DB_NAME=forbidden_stars.db SNAP_ROOT=/fs # корень снимка DATA_DIRS="uploads achievements" # тома, смонтированные в $SNAP_ROOT/<имя> LOCAL_REPO=/backup/repo STATE_DIR=/backup/state # статусы запусков, known_hosts VPS, lock RUNTIME_DIR=/tmp/fs-backup NEW=.restore-new # промежуточная директория восстановления (внутри тома) OLD=.restore-old # текущие данные на время замены UNPACK=.restore-unpack # распаковка архива при import PHASE=.restore-phase # фаза замены в томе (для recover после обрыва) # ─── Настройки (из environment compose) ─────────────────────────────────────── SNAP_HOST="${BACKUP_HOSTNAME:-fs-prod}" KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}" KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}" KEEP_MONTHLY="${BACKUP_KEEP_MONTHLY:-12}" VERIFY_SUBSET="${BACKUP_VERIFY_SUBSET:-10%}" MAX_AGE_HOURS="${BACKUP_MAX_AGE_HOURS:-30}" VPS_DIR="${BACKUP_VPS_DIR:-/srv/fs-backups/restic}" APP_HOST="${BACKUP_APP_HOST:-app}" export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}" export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}" export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}" TG_TOKEN="${BACKUP_TELEGRAM_BOT_TOKEN:-}" TG_CHATS="${BACKUP_TELEGRAM_CHAT_ID:-}" TG_API="${BACKUP_TELEGRAM_API:-https://api.telegram.org}" LAST_ERROR="" # текст последнего die — причина сбоя в отчёте Telegram # ─── Общие функции ──────────────────────────────────────────────────────────── # Весь служебный вывод — в stderr: stdout у export занят tar-потоком. log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; } die() { LAST_ERROR="$*"; log "ОШИБКА: $*"; exit 1; } require_password() { [ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)." } vps_enabled() { [ -n "${BACKUP_VPS_HOST:-}" ]; } repos() { if vps_enabled; then echo "local vps"; else echo "local"; fi; } repo_url() { case "$1" in local) echo "$LOCAL_REPO" ;; vps) echo "sftp:fs-vps:$VPS_DIR" ;; *) die "неизвестный репозиторий '$1' (local или vps)" ;; esac } check_repo_name() { case "$1" in local) ;; vps) vps_enabled || die "VPS не настроен: BACKUP_VPS_HOST в .env пуст." ;; *) die "неизвестный репозиторий '$1' (local или vps)" ;; esac } # r <аргументы restic…> r() { _url="$(repo_url "$1")" shift restic -r "$_url" "$@" } # SSH-доступ к VPS для restic (sftp:fs-vps:…): ключ из BACKUP_SSH_KEY_B64, known_hosts # хранится в томе — ключ хоста VPS запоминается при первом подключении. setup_ssh() { vps_enabled || return 0 [ -n "${BACKUP_SSH_KEY_B64:-}" ] || die "BACKUP_VPS_HOST задан, а BACKUP_SSH_KEY_B64 пуст — нечем входить на VPS." mkdir -p "$HOME/.ssh" "$RUNTIME_DIR" chmod 700 "$HOME/.ssh" "$RUNTIME_DIR" printf '%s' "$BACKUP_SSH_KEY_B64" | tr -d ' \r\n\t' | base64 -d > "$RUNTIME_DIR/id_backup" 2>/dev/null \ || die "BACKUP_SSH_KEY_B64 не декодируется из base64 — скопируйте строку заново (README, шаг 2)." chmod 600 "$RUNTIME_DIR/id_backup" grep -q 'PRIVATE KEY' "$RUNTIME_DIR/id_backup" \ || die "BACKUP_SSH_KEY_B64 — не приватный SSH-ключ (закодирован .pub вместо приватного?)." cat > "$HOME/.ssh/config" <"$STATE_DIR/lock" flock -n 9 || die "уже выполняется другая операция бэкапа — дождитесь окончания (docker compose logs -f backup)." } mark() { # mark <имя> ok|err <текст> printf '%s\t%s\n' "$(date +%s)" "$3" > "$STATE_DIR/$1.$2" if [ "$2" = ok ]; then rm -f "$STATE_DIR/$1.err"; fi } human() { # байты → «12.3 MB» awk -v b="${1:-0}" 'BEGIN { split("B KB MB GB TB", u, " "); i = 1; while (b >= 1024 && i < 5) { b /= 1024; i++ } printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }' } fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; } # ─── Telegram (#83) ─────────────────────────────────────────────────────────── # Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота # BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не # страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст. tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; } tg_escape() { sed -e 's/&/\&/g' -e 's//\>/g'; } tg_chat_ids() { printf '%s' "$TG_CHATS" | tr ',;' ' '; } tg_api() { # tg_api <метод> [аргументы curl…] → JSON ответа _method="$1" shift curl -fsS --max-time 70 "$@" "$TG_API/bot$TG_TOKEN/$_method" 2>/dev/null } tg_post() { # tg_post — одно сообщение в один чат tg_api sendMessage -o /dev/null \ --data-urlencode "chat_id=$1" \ --data-urlencode "text=$2" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "disable_web_page_preview=true" } tg_send() { # tg_send — во все чаты владельца tg_enabled || return 0 for _chat in $(tg_chat_ids); do tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено." done } repo_summary() { # repo_summary → «снимков 30 · 45.2 MB» if _st="$(r "$1" stats --mode raw-data --json 2>/dev/null)"; then printf '%s' "$_st" | jq -r '"\(.snapshots_count)\t\(.total_size)"' | { IFS="$(printf '\t')" read -r _cnt _size echo "снимков $_cnt · $(human "$_size")" } else echo "размер недоступен" fi } # Итог run/verify одним сообщением. Списки — «репо:поле:поле» через пробел; их собирают # cmd_run/cmd_verify, а вызывает обработчик EXIT — так отчёт уходит и при раннем отказе. notify_run() { # notify_run <код выхода> tg_enabled || return 0 _when="$(date '+%d.%m %H:%M')" _db="БД: игроков $_players, партий $_matches" if [ "$1" -eq 0 ]; then _text="✅ Бэкап $_when $_db" else _text="❌ Бэкап не удался $_when $(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)" [ "$_players" = "?" ] || _text="$_text $_db" fi for _item in $_ok_repos; do _text="$_text • ${_item%%:*}: снимок ${_item#*:} · $(repo_summary "${_item%%:*}")" done for _repo in $_failed; do _text="$_text • $_repo: ошибка" done [ "$1" -eq 0 ] || _text="$_text Подробности: docker compose logs backup" tg_send "$_text" } notify_verify() { # notify_verify <код выхода> tg_enabled || return 0 _when="$(date '+%d.%m %H:%M')" if [ "$1" -eq 0 ]; then _text="🔍 Проверка данных: OK $_when" else _text="❌ Проверка данных не прошла $_when $(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)" fi for _item in $_verified; do _rest="${_item#*:}" _text="$_text • ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}" done for _repo in $_failed; do _text="$_text • $_repo: ошибка" done [ "$1" -eq 0 ] || _text="$_text Подробности: docker compose logs backup" tg_send "$_text" } # Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются). db_ok() { [ -s "$1" ] || return 1 [ "$(sqlite3 "file:$1?immutable=1" 'PRAGMA integrity_check;' 2>&1)" = "ok" ] } db_counts() { # → «игроков|партий» sqlite3 "file:$1?immutable=1" \ "SELECT (SELECT count(*) FROM users WHERE role='player'), (SELECT count(*) FROM matches);" 2>/dev/null \ || echo "?|?" } # Консистентная копия живой БД в $SNAP_ROOT (VACUUM INTO — один снимок-транзакция, # приложению не мешает: в WAL читатели не блокируют писателей). stage_db() { [ -f "$DB_VOLUME/$DB_NAME" ] || die "БД $DB_VOLUME/$DB_NAME не найдена — приложение ещё ни разу не запускалось?" rm -f "$SNAP_ROOT/$DB_NAME" sqlite3 -cmd '.timeout 30000' "$DB_VOLUME/$DB_NAME" "VACUUM INTO '$SNAP_ROOT/$DB_NAME';" >&2 \ || die "не удалось снять копию БД." } # Создать репозиторий, если его ещё нет (формат v2 — со сжатием). ensure_repo() { set +e r "$1" cat config > /dev/null 2> "$RUNTIME_DIR/cat.err" _rc=$? set -e case "$_rc" in 0) return 0 ;; 10) log "Репозиторий $1 ещё не создан — создаю ($(repo_url "$1"), формат v2 со сжатием)…" r "$1" init --repository-version 2 >&2 ;; 12) die "неверный BACKUP_PASSWORD для репозитория $1 (пароль отличается от того, с которым он создан)." ;; *) cat "$RUNTIME_DIR/cat.err" >&2; log "Репозиторий $1 недоступен (код restic $_rc)."; return 1 ;; esac } backup_to() { # backup_to [--tag …] _repo="$1" shift ensure_repo "$_repo" || return 1 r "$_repo" backup --host "$SNAP_HOST" \ --exclude "$NEW" --exclude "$OLD" --exclude "$UNPACK" --exclude "$PHASE" \ "$@" "$SNAP_ROOT" >&2 } # Именованные снимки (run --tag …, метка keep) и страховочные (pre-restore) политика не # удаляет — только вручную: fs-backup restic forget --prune. Три последних # снимка остаются всегда (keep-daily иначе заменил бы более ранний снимок того же дня). forget_repo() { 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' } # resolve_snapshot → полный id (или die) resolve_snapshot() { if [ "$2" = latest ]; then _json="$(r "$1" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null)" || _json="[]" else _json="$(r "$1" snapshots "$2" --json 2>/dev/null)" || _json="[]" fi _id="$(printf '%s' "$_json" | jq -r '.[0].id // empty')" [ -n "$_id" ] || die "снимок '$2' не найден в репозитории $1 (список: fs-backup list $1)." echo "$_id" } warn_leftovers() { _vols="$(interrupted_volumes)" if [ -n "$_vols" ]; then log "ВНИМАНИЕ: восстановление было прервано (тома: ${_vols% }). Данные могут быть смешанными." log " Остановите app и выполните: fs-backup recover (README, раздел «Неполадки»)." fi } # ─── run ────────────────────────────────────────────────────────────────────── 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 case "$1" in *[!A-Za-z0-9._-]*|'') die "метка может содержать только латиницу, цифры, '.', '_', '-'" ;; esac _tag="$1" ;; *) die "run: неизвестный аргумент '$1'" ;; esac shift done _players="?" _matches="?" _ok_repos="" _failed="" # Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock, # нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой. trap '_rc=$?; notify_run "$_rc"' EXIT require_password setup_ssh take_lock warn_leftovers trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT log "Снимок данных: консистентная копия БД…" stage_db db_ok "$SNAP_ROOT/$DB_NAME" \ || die "копия БД не прошла PRAGMA integrity_check — снимок НЕ создан, старые снимки не тронуты." _counts="$(db_counts "$SNAP_ROOT/$DB_NAME")" _players="${_counts%%|*}" _matches="${_counts##*|}" log "БД в порядке: игроков $_players, партий $_matches." if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi for _repo in $(repos); do log "=== Репозиторий $_repo ($(repo_url "$_repo")) ===" if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \ && forget_repo "$_repo" \ && r "$_repo" check >&2; then _sid="$(latest_short_id "$_repo")" mark "run-$_repo" ok "снимок $_sid" _ok_repos="$_ok_repos $_repo:$_sid" log "OK: репозиторий $_repo, снимок $_sid." else mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup" _failed="$_failed $_repo" log "ОШИБКА в репозитории $_repo — остальные репозитории продолжаю." fi done [ -z "$_failed" ] || die "бэкап не удался в:$_failed" log "Бэкап завершён." } # ─── list ───────────────────────────────────────────────────────────────────── cmd_list() { _repo="${1:-local}" check_repo_name "$_repo" require_password setup_ssh _json="$(r "$_repo" snapshots --json)" || die "не удалось прочитать репозиторий $_repo." _n="$(printf '%s' "$_json" | jq 'length')" echo "Снимки в репозитории $_repo ($(repo_url "$_repo")): $_n шт., время — ${TZ:-UTC}" [ "$_n" -gt 0 ] || { echo " (пусто)"; return 0; } echo "ID Время Игроков Партий Размер Прирост Метки" printf '%s' "$_json" | jq -r ' def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?"; sort_by(.time) | .[] | [ .short_id, (.time[0:16] | sub("T"; " ")), tagval("players:"), tagval("matches:"), (.summary.total_bytes_processed // 0), (.summary.data_added_packed // .summary.data_added // 0), ([.tags[]? | select((startswith("players:") or startswith("matches:")) | not)] | join(",")) ] | @tsv' | awk -F '\t' ' function h(b, u, i) { split("B KB MB GB TB", u, " "); i = 1; while (b >= 1024 && i < 5) { b /= 1024; i++ } return sprintf(i == 1 ? "%d %s" : "%.1f %s", b, u[i]) } { printf "%-9s %-17s %7s %6s %9s %9s %s\n", $1, $2, $3, $4, h($5), h($6), $7 }' } # ─── status ─────────────────────────────────────────────────────────────────── print_mark() { # print_mark <подпись> <файл-без-суффикса> if [ -f "$2.ok" ]; then echo " $1 $(fmt_epoch "$(cut -f1 "$2.ok")") — $(cut -f2- "$2.ok")" else echo " $1 ещё не было" fi if [ -f "$2.err" ]; then echo " последняя ОШИБКА: $(fmt_epoch "$(cut -f1 "$2.err")") — $(cut -f2- "$2.err")" fi } cmd_status() { require_password setup_ssh echo "Бэкапы Forbidden Stars" echo " Расписание бэкапа: ${BACKUP_SCHEDULE:-отключено}" echo " Расписание проверки: ${BACKUP_VERIFY_SCHEDULE:-отключено}" echo " Часовой пояс: ${TZ:-UTC}" echo " Хранение: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. + 3 последних, именованные (keep) и pre-restore" echo " Сжатие restic: $RESTIC_COMPRESSION" echo " Имя хоста в снимках: $SNAP_HOST" vps_enabled || echo " VPS: не настроен (только локальная копия)" for _repo in $(repos); do echo echo "[$_repo] $(repo_url "$_repo")" print_mark "Последний бэкап:" "$STATE_DIR/run-$_repo" print_mark "Последняя проверка данных:" "$STATE_DIR/verify-$_repo" if _stats="$(r "$_repo" stats --mode raw-data --json 2>/dev/null)"; then printf '%s' "$_stats" | jq -r '"\(.snapshots_count)\t\(.total_size)\t\(.total_uncompressed_size // .total_size)\t\(.compression_space_saving // 0 | floor)"' | while IFS="$(printf '\t')" read -r _cnt _size _raw _saving; do echo " Снимков: $_cnt" echo " Размер репозитория: $(human "$_size") (без сжатия $(human "$_raw"), экономия ${_saving}%)" done else echo " Репозиторий: недоступен или ещё не создан" fi done warn_leftovers } # ─── health (healthcheck) ───────────────────────────────────────────────────── cmd_health() { [ -n "$RESTIC_PASSWORD" ] || { echo "BACKUP_PASSWORD не задан — бэкапы отключены"; exit 1; } [ -n "${BACKUP_SCHEDULE:-}" ] || { echo "расписание отключено"; exit 0; } _now="$(date +%s)" for _repo in $(repos); do [ -f "$STATE_DIR/run-$_repo.ok" ] || { echo "$_repo: успешных бэкапов ещё не было"; exit 1; } _ts="$(cut -f1 "$STATE_DIR/run-$_repo.ok")" if [ $((_now - _ts)) -gt $((MAX_AGE_HOURS * 3600)) ]; then echo "$_repo: последний успешный бэкап старше $MAX_AGE_HOURS ч" exit 1 fi done echo ok } cmd_has_snapshots() { [ -n "$RESTIC_PASSWORD" ] || exit 1 _n="$(r local snapshots --json 2>/dev/null | jq 'length' 2>/dev/null)" || exit 1 [ "${_n:-0}" -gt 0 ] } # ─── verify ─────────────────────────────────────────────────────────────────── cmd_verify() { _verified="" _failed="" trap '_rc=$?; notify_verify "$_rc"' EXIT require_password setup_ssh take_lock for _repo in $(repos); do log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ===" _tmp="$RUNTIME_DIR/verify.db" rm -f "$_tmp" if r "$_repo" check --read-data-subset="$VERIFY_SUBSET" >&2 \ && r "$_repo" dump --host "$SNAP_HOST" latest "$SNAP_ROOT/$DB_NAME" > "$_tmp" \ && db_ok "$_tmp"; then _counts="$(db_counts "$_tmp")" mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}" _verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}" log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})." else mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup" _failed="$_failed $_repo" log "ОШИБКА проверки репозитория $_repo." fi rm -f "$_tmp" done [ -z "$_failed" ] || die "проверка не прошла в:$_failed" log "Проверка завершена." } # ─── export ─────────────────────────────────────────────────────────────────── cmd_export() { _snap="" _repo=local while [ $# -gt 0 ]; do case "$1" in --repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;; -*) die "export: неизвестный аргумент '$1'" ;; *) _snap="$1" ;; esac shift done check_repo_name "$_repo" [ ! -t 1 ] || die "export пишет tar в stdout — перенаправьте в файл: docker compose exec -T backup fs-backup export latest > fs.tar" require_password setup_ssh _id="$(resolve_snapshot "$_repo" "${_snap:-latest}")" log "Экспорт снимка ${_id%"${_id#????????}"} из $_repo (tar без сжатия)…" r "$_repo" dump --archive tar "$_id:$SNAP_ROOT" / log "Экспорт завершён." } # info [--repo …] → «short_idYYYYmmdd_HHMM» (для скриптов на ПК) cmd_info() { _snap="latest" _repo=local while [ $# -gt 0 ]; do case "$1" in --repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;; *) _snap="$1" ;; esac shift done check_repo_name "$_repo" require_password setup_ssh _id="$(resolve_snapshot "$_repo" "$_snap")" r "$_repo" snapshots "$_id" --json | jq -r '.[0] | "\(.short_id)\t\(.time[0:16] | gsub("[-:]"; "") | sub("T"; "_"))"' } # ─── restore / import: общая часть ─────────────────────────────────────────── # Отказ, если приложение запущено: любой HTTP-ответ от app:8000 (даже ошибка) значит, что # uvicorn жив. Остановленный контейнер не резолвится в сети compose — ответа не будет. ensure_app_stopped() { if wget -S -T 3 -O /dev/null "http://$APP_HOST:8000/api/health" 2>&1 | grep -q 'HTTP/'; then die "приложение работает — восстанавливать поверх него нельзя. Сначала: docker compose stop app" fi } check_space() { # check_space <нужно байт> _need_kb=$(( ${1:-0} * 11 / 10 / 1024 + 51200 )) # +10% и 50 МБ запаса for _d in "$DB_VOLUME" "$SNAP_ROOT/uploads" "$SNAP_ROOT/achievements"; do _avail="$(df -Pk "$_d" | awk 'NR == 2 { print $4 }')" [ "${_avail:-0}" -ge "$_need_kb" ] \ || die "мало места в $_d: свободно $(human $((_avail * 1024))), нужно ~$(human $((_need_kb * 1024)))." done } clean_staging() { for _p in "${DB_VOLUME:?}/$NEW" "${SNAP_ROOT:?}/uploads/$UNPACK" "$SNAP_ROOT/uploads/$NEW" "$SNAP_ROOT/achievements/$NEW"; do [ -e "$_p" ] || continue chmod -R u+rwx "$_p" 2>/dev/null || true # каталоги без права записи иначе не удалить rm -rf "$_p" done } prepare_staging() { clean_staging mkdir -p "$DB_VOLUME/$NEW" for _d in $DATA_DIRS; do mkdir -p "$SNAP_ROOT/$_d/$NEW"; done } count_files() { find "$1" -type f | wc -l | tr -d ' '; } # Шаг 3: проверка развёрнутых данных. verify_staging <файлов uploads> <файлов achievements> verify_staging() { log "Проверка развёрнутых данных…" _db="$DB_VOLUME/$NEW/$DB_NAME" [ -s "$_db" ] || die "в восстанавливаемых данных нет БД — текущие данные НЕ тронуты." db_ok "$_db" || die "восстановленная БД не прошла PRAGMA integrity_check — текущие данные НЕ тронуты." _tables="$(sqlite3 "file:$_db?immutable=1" \ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('users','matches','alembic_version');")" [ "$_tables" = 3 ] || die "в восстановленной БД нет ключевых таблиц — текущие данные НЕ тронуты." set -- "$1" "$2" for _d in $DATA_DIRS; do _got="$(count_files "$SNAP_ROOT/$_d/$NEW")" [ "$_got" -eq "$1" ] || die "$_d: развёрнуто $_got файлов, ожидалось $1 — текущие данные НЕ тронуты." shift done _counts="$(db_counts "$_db")" log "Развёрнутые данные в порядке: игроков ${_counts%%|*}, партий ${_counts##*|}." } # Шаг 4: страховочный снимок текущих данных в локальный репозиторий. pre_restore_snapshot() { _has_files="$(find "$SNAP_ROOT/uploads" "$SNAP_ROOT/achievements" -mindepth 1 -maxdepth 1 \ ! -name "$NEW" ! -name "$OLD" ! -name "$UNPACK" | head -n 1)" if [ ! -f "$DB_VOLUME/$DB_NAME" ] && [ -z "$_has_files" ]; then log "Текущих данных нет — страховочный снимок не нужен." return 0 fi log "Страховочный снимок текущих данных (метка pre-restore)…" rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal" if [ -f "$DB_VOLUME/$DB_NAME" ]; then if ! sqlite3 -cmd '.timeout 30000' "$DB_VOLUME/$DB_NAME" "VACUUM INTO '$SNAP_ROOT/$DB_NAME';" 2>/dev/null; then log "Текущая БД не читается штатно — сохраняю её файлы как есть." rm -f "$SNAP_ROOT/$DB_NAME" cp "$DB_VOLUME/$DB_NAME" "$SNAP_ROOT/$DB_NAME" if [ -f "$DB_VOLUME/$DB_NAME-wal" ]; then cp "$DB_VOLUME/$DB_NAME-wal" "$SNAP_ROOT/$DB_NAME-wal"; fi fi fi _counts="$(db_counts "$SNAP_ROOT/$DB_NAME")" if ! backup_to local --tag pre-restore --tag "players:${_counts%%|*}" --tag "matches:${_counts##*|}"; then rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal" die "страховочный снимок не удался — текущие данные НЕ тронуты." fi rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal" log "Страховочный снимок: $(latest_short_id local)." } # Элементы тома, участвующие в замене. Для БД — только файлы SQLite (в томе db-data # лежат ещё точки монтирования uploads/achievements приложения — их не трогаем). move_items() { # move_items <из> <в> if [ "$3" = db ]; then for _f in "$DB_NAME" "$DB_NAME-wal" "$DB_NAME-shm" "$DB_NAME-journal"; do if [ -e "$1/$_f" ]; then mv "$1/$_f" "$2/" || return 1; fi done return 0 fi for _item in "$1"/* "$1"/.[!.]* "$1"/..?*; do [ -e "$_item" ] || [ -L "$_item" ] || continue case "${_item##*/}" in "$NEW"|"$OLD"|"$UNPACK"|"$PHASE") continue ;; esac mv "$_item" "$2/" || return 1 done } volume_path() { if [ "$1" = db ]; then echo "$DB_VOLUME"; else echo "$SNAP_ROOT/$1"; fi; } volume_mode() { if [ "$1" = db ]; then echo db; else echo all; fi; } # Замена в одном томе в две фазы; файл фазы ($PHASE) переживает обрыв питания, по нему # fs-backup recover понимает, как вернуть данные. # old — фаза A: текущие данные переносятся в .restore-old (новые ещё не тронуты) # new — фаза B: новые данные переносятся из .restore-new на место (старые целиком в .restore-old) # swapped — том заменён; committed — заменены все тома, идёт уборка .restore-old # Код возврата: 0 — заменено, 1 — сбой в фазе A, 2 — сбой в фазе B. swap_in() { # swap_in <том> echo old > "$1/$PHASE" || return 1 mkdir -p "$1/$OLD" || return 1 move_items "$1" "$1/$OLD" "$2" || return 1 echo new > "$1/$PHASE" || return 1 move_items "$1/$NEW" "$1" "$2" || return 2 rm -rf "${1:?}/$NEW" echo swapped > "$1/$PHASE" || return 2 } # Вернуть тому прежнее содержимое. <1|2> — фаза сбоя: в фазе B (и для уже заменённого тома) # сначала убрать новые данные обратно в .restore-new, в фазе A на месте лежат только старые. swap_back() { # swap_back <том> <1|2> if [ "$3" = 2 ]; then mkdir -p "$1/$NEW" move_items "$1" "$1/$NEW" "$2" || true fi move_items "$1/$OLD" "$1" "$2" || true if rmdir "$1/$OLD" 2>/dev/null || [ ! -d "$1/$OLD" ]; then rm -f "$1/$PHASE" else log "ВНИМАНИЕ: в $1/$OLD остались файлы, которые не удалось вернуть — перенесите их вручную." fi } # Шаги 4–6: страховка, замена через rename в пределах тома, уборка. apply_staging() { # apply_staging if [ "$1" = no ]; then pre_restore_snapshot; fi log "Замена данных (rename в пределах каждого тома)…" _done="" for _v in db $DATA_DIRS; do _vp="$(volume_path "$_v")" _vm="$(volume_mode "$_v")" set +e swap_in "$_vp" "$_vm" _rc=$? set -e if [ "$_rc" -ne 0 ]; then log "Сбой замены в томе '$_v' — возвращаю прежние данные во все тома…" swap_back "$_vp" "$_vm" "$_rc" for _u in $_done; do swap_back "$(volume_path "$_u")" "$(volume_mode "$_u")" 2; done die "замена не удалась, прежние данные возвращены на место." fi _done="$_v $_done" done for _v in db $DATA_DIRS; do echo committed > "$(volume_path "$_v")/$PHASE"; done for _v in db $DATA_DIRS; do _vp="$(volume_path "$_v")" rm -rf "${_vp:?}/$OLD" rm -f "$_vp/$PHASE" done trap - EXIT log "Данные восстановлены. Запустите приложение: docker compose start app" } # Следы прерванной замены (обрыв питания, kill): файлы фазы или .restore-old в томах. # Вывод — имена томов в одну строку через пробел. interrupted_volumes() { for _v in db $DATA_DIRS; do _vp="$(volume_path "$_v")" if [ -f "$_vp/$PHASE" ] || [ -d "$_vp/$OLD" ]; then printf '%s ' "$_v"; fi done } refuse_if_interrupted() { [ -z "$(interrupted_volumes)" ] \ || die "найдены следы прерванного восстановления — сначала выполните: fs-backup recover" } parse_restore_args() { # общие флаги restore/import → _pos _repo _yes _skip _pos="" _repo=local _yes=no _skip=no while [ $# -gt 0 ]; do case "$1" in --repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;; --yes) _yes=yes ;; --no-pre-restore) _skip=yes ;; -*) die "неизвестный аргумент '$1'" ;; *) _pos="$1" ;; esac shift done } # ─── restore ────────────────────────────────────────────────────────────────── cmd_restore() { parse_restore_args "$@" [ -n "$_pos" ] || die "укажите снимок: fs-backup restore [--repo local|vps] --yes" check_repo_name "$_repo" require_password setup_ssh take_lock ensure_app_stopped refuse_if_interrupted _id="$(resolve_snapshot "$_repo" "$_pos")" r "$_repo" snapshots "$_id" --compact >&2 || true if [ "$_yes" != yes ]; then log "Этот снимок ЗАМЕНИТ текущие БД, uploads и achievements (текущие попадут в снимок pre-restore)." log "Если всё верно — повторите команду с --yes." exit 2 fi # Шаг 1: объём и число файлов по манифесту снимка. _manifest="$RUNTIME_DIR/manifest.tsv" r "$_repo" ls --json "$_id" | jq -r 'select(.struct_type == "node" and .type == "file") | "\(.size)\t\(.path)"' > "$_manifest" \ || die "не удалось прочитать содержимое снимка." _total="$(awk -F '\t' '{ s += $1 } END { print s + 0 }' "$_manifest")" _n_uploads="$(awk -F '\t' -v p="$SNAP_ROOT/uploads/" 'index($2, p) == 1 { n++ } END { print n + 0 }' "$_manifest")" _n_ach="$(awk -F '\t' -v p="$SNAP_ROOT/achievements/" 'index($2, p) == 1 { n++ } END { print n + 0 }' "$_manifest")" log "Снимок: $(human "$_total"), файлов в uploads: $_n_uploads, в achievements: $_n_ach." check_space "$_total" # Шаг 2: разворачивание в .restore-new внутри каждого тома. trap 'clean_staging' EXIT prepare_staging log "Разворачивание снимка в промежуточные директории ($NEW)…" r "$_repo" dump "$_id" "$SNAP_ROOT/$DB_NAME" > "$DB_VOLUME/$NEW/$DB_NAME" \ || die "не удалось извлечь БД из снимка — текущие данные НЕ тронуты." for _d in $DATA_DIRS; do r "$_repo" restore "$_id:$SNAP_ROOT/$_d" --target "$SNAP_ROOT/$_d/$NEW" --verify >&2 \ || die "не удалось развернуть $_d — текущие данные НЕ тронуты." done # Шаг 3–6. verify_staging "$_n_uploads" "$_n_ach" apply_staging "$_skip" } # ─── import ─────────────────────────────────────────────────────────────────── cmd_import() { parse_restore_args "$@" _file="$_pos" [ -n "$_file" ] || die "укажите архив: fs-backup import /import/fs.tar --yes" [ -f "$_file" ] || die "файл $_file не найден. Скопируйте архив в контейнер: docker compose cp fs.tar backup:/import/fs.tar" if [ "$_skip" = no ]; then require_password; fi take_lock ensure_app_stopped refuse_if_interrupted # Сжатие определяем по сигнатуре: gzip (старые fs_*.tar.gz) или обычный tar (export). _z="" if [ "$(head -c 2 "$_file" | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then _z="-z"; fi _list="$RUNTIME_DIR/import.list" tar $_z -tf "$_file" > "$_list.raw" 2>/dev/null || die "архив повреждён или это не tar." sed -e 's#^\./##' -e 's#^/##' "$_list.raw" > "$_list" # Tar, обрезанный ровно по границе файла, читается «успешно», но без хвоста. Целый tar # не меньше суммы (заголовок 512 + данные с выравниванием до 512) по всем записям плюс # два нулевых блока маркера конца. У .tar.gz обрыв и так ловит CRC gzip. if [ -z "$_z" ]; then _min="$(tar -tvf "$_file" | awk '{ t += 512 + int(($3 + 511) / 512) * 512 } END { print t + 1024 }')" [ "$(stat -c %s "$_file")" -ge "$_min" ] || die "архив обрезан (нет конца tar) — скачайте его заново." fi grep -qx "$DB_NAME" "$_list" || die "в архиве нет $DB_NAME — это не бэкап Forbidden Stars." _n_uploads="$(grep '^uploads/.' "$_list" | grep -vc '/$' || true)" _n_ach="$(grep '^achievements/.' "$_list" | grep -vc '/$' || true)" _size="$(stat -c %s "$_file")" if [ -n "$_z" ]; then _size=$((_size * 2)); fi log "Архив: $_file ($(human "$(stat -c %s "$_file")")), файлов в uploads: $_n_uploads, в achievements: $_n_ach." if [ "$_yes" != yes ]; then log "Архив ЗАМЕНИТ текущие БД, uploads и achievements (текущие попадут в снимок pre-restore)." log "Если всё верно — повторите команду с --yes." exit 2 fi check_space "$_size" # Шаг 2: распаковка в том uploads (самый большой), затем раскладка по .restore-new томов: # uploads — rename в пределах тома, БД и achievements (маленькие) — копированием. trap 'clean_staging' EXIT prepare_staging _u="$SNAP_ROOT/uploads/$UNPACK" mkdir -p "$_u" log "Распаковка архива в промежуточную директорию…" tar $_z -xf "$_file" -C "$_u" || die "не удалось распаковать архив — текущие данные НЕ тронуты." cp "$_u/$DB_NAME" "$DB_VOLUME/$NEW/$DB_NAME" if [ -d "$_u/uploads" ]; then rmdir "$SNAP_ROOT/uploads/$NEW" mv "$_u/uploads" "$SNAP_ROOT/uploads/$NEW" fi if [ -d "$_u/achievements" ]; then cp -a "$_u/achievements/." "$SNAP_ROOT/achievements/$NEW/"; fi rm -rf "$_u" verify_staging "$_n_uploads" "$_n_ach" apply_staging "$_skip" } # ─── recover: разбор прерванного восстановления ────────────────────────────── # Если все тома успели замениться (committed) — дочищаем, новые данные остаются. # Иначе возвращаем каждому тому прежние данные по его фазе; затем restore можно повторить. cmd_recover() { take_lock ensure_app_stopped _vols="$(interrupted_volumes)" if [ -z "$_vols" ]; then log "Следов прерванного восстановления нет — делать ничего не нужно." return 0 fi _committed=no for _v in $_vols; do if [ "$(cat "$(volume_path "$_v")/$PHASE" 2>/dev/null)" = committed ]; then _committed=yes; fi done if [ "$_committed" = yes ]; then log "Замена успела завершиться во всех томах — дочищаю промежуточные директории…" for _v in $_vols; do _vp="$(volume_path "$_v")" rm -rf "${_vp:?}/$OLD" rm -f "$_vp/$PHASE" done clean_staging log "Готово: восстановленные данные на месте. Запустите приложение: docker compose start app" return 0 fi log "Замена не завершилась — возвращаю прежние данные (тома: ${_vols% })…" for _v in $_vols; do _vp="$(volume_path "$_v")" case "$(cat "$_vp/$PHASE" 2>/dev/null)" in old) swap_back "$_vp" "$(volume_mode "$_v")" 1 ;; *) swap_back "$_vp" "$(volume_mode "$_v")" 2 ;; esac done [ -z "$(interrupted_volumes)" ] || die "вернуть удалось не всё — см. сообщения выше." clean_staging log "Готово: данные — как до восстановления. Запустите приложение (docker compose start app)" log "или повторите восстановление нужного снимка." } # ─── restic (для продвинутых операций) ──────────────────────────────────────── cmd_restic() { [ $# -ge 1 ] || die "использование: fs-backup restic <команда restic…>" _repo="$1" shift check_repo_name "$_repo" require_password setup_ssh r "$_repo" "$@" } cmd_init() { require_password setup_ssh for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done } # ─── telegram ───────────────────────────────────────────────────────────────── tg_help_text() { cat <<'EOF' Бэкапы Forbidden Stars /backups — хранящиеся снимки по репозиториям /status — последние бэкапы и проверки, размеры Отчёт о каждом бэкапе и проверке приходит сюда сам. EOF } tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних [ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; } setup_ssh for _repo in $(repos); do echo "$_repo · $(repo_summary "$_repo")" if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then printf '%s' "$_json" | jq -r ' def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?"; def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)]; sort_by(.time) | .[-10:] | reverse | .[] | "\(.time[5:16] | sub("T"; " ")) игроков \(tagval("players:")), партий \(tagval("matches:"))" + (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)' else echo "репозиторий недоступен или занят — повторите позже" fi echo done } tg_allowed() { # tg_allowed — чат из белого списка владельца for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done return 1 } tg_reply() { # tg_reply <текст команды> # Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота. case "$2" in /backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;; /status*) _reply="
$(cmd_status 2>&1 | head -n 60 | tg_escape)
" ;; *) _reply="$(tg_help_text)" ;; esac [ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup." tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен." } # Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие # пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение # restic совместимо с идущим бэкапом. cmd_telegram_bot() { tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота." _offset_file="$STATE_DIR/telegram.offset" _updates="$RUNTIME_DIR/telegram-updates" log "Telegram-бот: слушаю команды /backups, /status, /help." while :; do _offset="$(cat "$_offset_file" 2>/dev/null || echo 0)" if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \ --data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then sleep 30 continue fi # Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются. printf '%s' "$_resp" | jq -r '.result[]? | [.update_id, (.message.chat.id // ""), (.message.from.username // ""), ((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \ > "$_updates" 2>/dev/null || { sleep 30; continue; } while IFS="$(printf '\037')" read -r _uid _chat _from _text; do echo $((_uid + 1)) > "$_offset_file" [ -n "$_chat" ] || continue if tg_allowed "$_chat"; then tg_reply "$_chat" "$_text" else log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID." fi done < "$_updates" done } # Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не # подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он # забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»). cmd_telegram_chats() { [ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN." _resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \ || die "Telegram не ответил (неверный токен или нет сети)." _chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null) | "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')" if [ -z "$_chats" ]; then echo "Сообщений боту нет: напишите ему /start в Telegram и повторите." return 0 fi echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):" printf '%s\n' "$_chats" } cmd_telegram() { case "${1:-}" in bot) cmd_telegram_bot ;; chats) cmd_telegram_chats ;; test) tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)." _sent=0 for _chat in $(tg_chat_ids); do if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then _sent=$((_sent + 1)) log "Telegram: тестовое сообщение в чат $_chat отправлено." else log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)." fi done [ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло." ;; *) die "telegram: укажите chats, test или bot" ;; esac } usage() { cat <<'EOF' fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml: docker compose exec backup fs-backup <команда> Повседневное: status состояние: последние бэкапы/проверки, размеры репозиториев list [local|vps] хронология снимков (время, игроков, партий, размер, прирост) run [--tag имя] [--allow-empty] сделать снимок сейчас; с --tag снимок именованный и очисткой не удаляется (например, --tag before-update); --allow-empty — разрешить снимок пустой БД, когда в истории есть данные verify проверить целостность данных в репозиториях Восстановление (сначала: docker compose stop app; после: docker compose start app): restore [--repo local|vps] --yes восстановить снимок (через промежуточную директорию, текущие данные сохраняются в снимок pre-restore) import /import/<файл.tar|.tar.gz> --yes восстановить из архива (export или старый fs_*.tar.gz); архив положить в контейнер: docker compose cp fs.tar backup:/import/ recover разобрать прерванное восстановление (обрыв питания и т.п.) Выгрузка: export [--repo local|vps] > fs.tar снимок в tar без сжатия (нужен exec -T) Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status): telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID) telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID telegram bot бот-слушатель команд (контейнер запускает его сам) Прочее: init создать репозитории (делается автоматически) restic <аргументы> произвольная команда restic с настройками контейнера health проверка для healthcheck Подробная инструкция: deploy/backup/README.md EOF } # ─── main ───────────────────────────────────────────────────────────────────── mkdir -p "$RUNTIME_DIR" "$STATE_DIR" 2>/dev/null || true _cmd="${1:-help}" [ $# -eq 0 ] || shift case "$_cmd" in run) cmd_run "$@" ;; list) cmd_list "$@" ;; status) cmd_status "$@" ;; verify) cmd_verify "$@" ;; restore) cmd_restore "$@" ;; import) cmd_import "$@" ;; recover) cmd_recover "$@" ;; export) cmd_export "$@" ;; info) cmd_info "$@" ;; init) cmd_init "$@" ;; restic) cmd_restic "$@" ;; health) cmd_health "$@" ;; has-snapshots) cmd_has_snapshots "$@" ;; telegram) cmd_telegram "$@" ;; help|-h|--help) usage ;; *) usage >&2; exit 2 ;; esac