@@ -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 <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