Files
ForbiddenStarsApp/deploy/backup/fs-backup.sh
T
NotBigGhostandClaude Opus 5 f73316f37f Бэкап: образ restic-сайдкара и скрипт fs-backup
deploy/backup: образ на restic/restic:0.19.1 (+ sqlite, supercronic, tini), работает от
uid 10001, как appuser. Скрипт fs-backup:
- run: консистентная копия БД (VACUUM INTO + integrity_check), снимок в локальный
  репозиторий и на VPS (SFTP), теги players/matches, GFS-очистка (дни/недели/месяцы;
  именованные keep и pre-restore не удаляются), restic check;
- list / status / verify / export (tar без сжатия; сжатие — только restic, репозиторий v2);
- restore / import через промежуточную директорию: разворачивание в .restore-new внутри
  каждого тома, проверка (integrity_check, таблицы, число файлов, целостность tar),
  страховочный снимок pre-restore, двухфазная замена rename с файлом фазы и откатом;
  recover разбирает прерванное восстановление. Отказ при работающем app.
Точка входа: без BACKUP_PASSWORD простой без рестарт-петли, первый снимок при пустом
репозитории, расписание supercronic, healthcheck по возрасту последнего успеха. #64

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jBxs9nBCk5nBdTLzcGz91
2026-09-14 01:04:12 +03:00

815 lines
40 KiB
Bash
Raw 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.
#!/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}"
# ─── Общие функции ────────────────────────────────────────────────────────────
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
die() { 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 <local|vps> <аргументы 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" <<EOF
Host fs-vps
HostName ${BACKUP_VPS_HOST}
User ${BACKUP_VPS_USER:-fsbackup}
Port ${BACKUP_VPS_PORT:-22}
IdentityFile $RUNTIME_DIR/id_backup
IdentitiesOnly yes
BatchMode yes
StrictHostKeyChecking accept-new
UserKnownHostsFile $STATE_DIR/known_hosts
ServerAliveInterval 30
ServerAliveCountMax 6
EOF
chmod 600 "$HOME/.ssh/config"
}
# Одна операция за раз (расписание и ручные команды не должны пересекаться).
take_lock() {
exec 9>"$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"; }
# Проверка 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 <repo> [--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 <repo> forget <id> --prune.
forget_repo() {
log "Очистка по политике: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. (+ все keep и pre-restore)…"
r "$1" forget --host "$SNAP_HOST" --group-by host \
--keep-daily "$KEEP_DAILY" --keep-weekly "$KEEP_WEEKLY" --keep-monthly "$KEEP_MONTHLY" \
--keep-tag keep --keep-tag pre-restore --prune >&2
}
latest_short_id() {
r "$1" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r '.[0].short_id // empty'
}
# resolve_snapshot <repo> <id|latest> → полный 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=""
while [ $# -gt 0 ]; do
case "$1" in
--scheduled) _kind=scheduled ;;
--tag)
[ $# -ge 2 ] || die "--tag требует значение"
shift
case "$1" in *[!A-Za-z0-9._-]*|'') die "метка может содержать только латиницу, цифры, '.', '_', '-'" ;; esac
_tag="$1"
;;
*) die "run: неизвестный аргумент '$1'" ;;
esac
shift
done
require_password
setup_ssh
take_lock
warn_leftovers
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' 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."
_failed=""
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"
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 мес. + именованные (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() {
require_password
setup_ssh
take_lock
_failed=""
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##*|}"
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 <id|latest> [--repo …] → «short_id<TAB>YYYYmmdd_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 <из> <в> <all|db>
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 <том> <all|db>
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 <том> <all|db> <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 <skip_safety yes|no>
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 <id|latest> [--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 <local|vps> <команда 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
}
usage() {
cat <<'EOF'
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
docker compose exec backup fs-backup <команда>
Повседневное:
status состояние: последние бэкапы/проверки, размеры репозиториев
list [local|vps] хронология снимков (время, игроков, партий, размер, прирост)
run [--tag имя] сделать снимок сейчас; с --tag снимок именованный и очисткой
не удаляется (например, --tag before-update)
verify проверить целостность данных в репозиториях
Восстановление (сначала: docker compose stop app; после: docker compose start app):
restore <id|latest> [--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 <id|latest> [--repo local|vps] > fs.tar
снимок в tar без сжатия (нужен exec -T)
Прочее:
init создать репозитории (делается автоматически)
restic <local|vps> <аргументы> произвольная команда 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 "$@" ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac