From f73316f37f7304b0ce081fd39bdc3b8ccf12f1d4 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 01:04:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D1=8D=D0=BA=D0=B0=D0=BF:=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=20restic-=D1=81=D0=B0=D0=B9=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=20=D0=B8=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=20fs-backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013jBxs9nBCk5nBdTLzcGz91 --- deploy/backup/.dockerignore | 6 + deploy/backup/Dockerfile | 33 ++ deploy/backup/entrypoint.sh | 38 ++ deploy/backup/fs-backup.sh | 814 ++++++++++++++++++++++++++++++++++++ 4 files changed, 891 insertions(+) create mode 100644 deploy/backup/.dockerignore create mode 100644 deploy/backup/Dockerfile create mode 100644 deploy/backup/entrypoint.sh create mode 100644 deploy/backup/fs-backup.sh diff --git a/deploy/backup/.dockerignore b/deploy/backup/.dockerignore new file mode 100644 index 0000000..145b818 --- /dev/null +++ b/deploy/backup/.dockerignore @@ -0,0 +1,6 @@ +# В контекст сборки попадают ТОЛЬКО файлы образа. Всё остальное (в первую очередь приватный +# ключ deploy/backup/id_backup, если он лежит здесь на ПК) в образ не попадёт. +* +!Dockerfile +!fs-backup.sh +!entrypoint.sh diff --git a/deploy/backup/Dockerfile b/deploy/backup/Dockerfile new file mode 100644 index 0000000..27bfb70 --- /dev/null +++ b/deploy/backup/Dockerfile @@ -0,0 +1,33 @@ +# Контейнер бэкапов: restic (дедупликация, шифрование, сжатие zstd) + расписание supercronic. +# Снимает консистентную копию SQLite и файлы томов приложения, хранит снимки локально (том +# backup-data) и на VPS (SFTP). Управление — команда fs-backup (см. deploy/backup/README.md). +FROM restic/restic:0.19.1 + +# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root; +# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе. +RUN apk add --no-cache sqlite supercronic tini + +# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают +# правильного владельца, а -wal/-shm SQLite никогда не достаются root. +RUN addgroup -g 10001 fsbackup \ + && adduser -D -u 10001 -G fsbackup -h /home/fsbackup fsbackup \ + && mkdir -p /fs/uploads /fs/achievements /fs-db /backup/repo /backup/state /backup/cache /import \ + && chown -R 10001:10001 /fs /fs-db /backup /import /home/fsbackup + +COPY fs-backup.sh /usr/local/bin/fs-backup +COPY entrypoint.sh /usr/local/bin/fs-backup-entrypoint +RUN sed -i 's/\r$//' /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint \ + && chmod 755 /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint + +ENV HOME=/home/fsbackup \ + RESTIC_CACHE_DIR=/backup/cache \ + TZ=Europe/Moscow + +USER 10001:10001 +WORKDIR /home/fsbackup + +# Здоров = последний успешный бэкап свежее BACKUP_MAX_AGE_HOURS (в каждом репозитории). +HEALTHCHECK --interval=10m --timeout=60s --start-period=2h --retries=1 \ + CMD ["fs-backup", "health"] + +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/fs-backup-entrypoint"] diff --git a/deploy/backup/entrypoint.sh b/deploy/backup/entrypoint.sh new file mode 100644 index 0000000..e1028ca --- /dev/null +++ b/deploy/backup/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Точка входа контейнера backup: первый снимок (если снимков ещё нет) и расписание. +# Расписание — supercronic по BACKUP_SCHEDULE (бэкап) и BACKUP_VERIFY_SCHEDULE (проверка). +set -eu + +log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } + +# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём, +# пока пароль появится в .env; healthcheck при этом показывает unhealthy. +if [ -z "${BACKUP_PASSWORD:-}" ]; then + log "BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ." + log "Настройка по шагам: deploy/backup/README.md. После правки .env: docker compose up -d backup" + exec tail -f /dev/null +fi + +mkdir -p /tmp/fs-backup +CRONTAB=/tmp/fs-backup/crontab +: > "$CRONTAB" +if [ -n "${BACKUP_SCHEDULE:-}" ]; then + echo "${BACKUP_SCHEDULE} fs-backup run --scheduled" >> "$CRONTAB" +fi +if [ -n "${BACKUP_VERIFY_SCHEDULE:-}" ]; then + echo "${BACKUP_VERIFY_SCHEDULE} fs-backup verify" >> "$CRONTAB" +fi + +if [ ! -s "$CRONTAB" ]; then + log "Расписание отключено — только ручной запуск: docker compose exec backup fs-backup run" + exec tail -f /dev/null +fi + +if ! fs-backup has-snapshots; then + log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу." + fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию." +fi + +log "Расписание (TZ=${TZ:-UTC}):" +sed 's/^/[backup] /' "$CRONTAB" +exec supercronic -passthrough-logs "$CRONTAB" diff --git a/deploy/backup/fs-backup.sh b/deploy/backup/fs-backup.sh new file mode 100644 index 0000000..79c54c5 --- /dev/null +++ b/deploy/backup/fs-backup.sh @@ -0,0 +1,814 @@ +#!/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 <аргументы 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"; } + +# Проверка 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. +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 → полный 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 [--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 +} + +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 [--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) + +Прочее: + 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 "$@" ;; + help|-h|--help) usage ;; + *) usage >&2; exit 2 ;; +esac