#!/usr/bin/env bash # Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup # прода на Pi по SSH или (с --test) в локальный тест-клон (docker-compose.test.yml). # На Windows удобнее scripts/fs-backup.ps1 — поведение то же. Инструкция: deploy/backup/README.md # # scripts/fs-backup.sh status состояние бэкапов на Pi # scripts/fs-backup.sh list [vps] хронология снимков # scripts/fs-backup.sh now [--tag before-update] сделать снимок сейчас # scripts/fs-backup.sh verify проверить целостность данных # scripts/fs-backup.sh pull [|latest] [--repo vps] # скачать снимок в backups/ (сверка sha256) # scripts/fs-backup.sh restore-test <файл.tar|.tar.gz> # учебное восстановление в локальный тест-клон # --test первым аргументом — status/list/now/verify/pull для локального тест-клона # # Настройки — из корневого .env (переменная окружения с тем же именем важнее): # BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config) # BACKUP_PI_DIR папка на Pi с docker-compose.yml и .env (по умолчанию ~/forbidden-stars) set -euo pipefail PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TEST_COMPOSE="$PROJECT_DIR/docker-compose.test.yml" export MSYS_NO_PATHCONV=1 # Git Bash: не переписывать /import/... в аргументах docker die() { echo "ОШИБКА: $*" >&2; exit 1; } setting() { # setting <ключ> <по умолчанию>: окружение, затем .env (последнее присваивание) local val="${!1:-}" if [ -z "$val" ] && [ -f "$PROJECT_DIR/.env" ]; then val="$(grep -E "^\s*$1\s*=" "$PROJECT_DIR/.env" | tail -n 1 | cut -d= -f2- | sed -e 's/\s*#.*$//' -e 's/^\s*//' -e 's/\s*$//' | tr -d '\r"')" fi printf '%s' "${val:-$2}" } PI_SSH="$(setting BACKUP_PI_SSH "")" # shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi PI_DIR="$(setting BACKUP_PI_DIR "~/forbidden-stars")" TARGET="pi" if [ "${1:-}" = "--test" ]; then TARGET="test"; shift; fi native_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi; } sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } pi() { # pi : выполнить на Pi в папке прода [ -n "$PI_SSH" ] || die "задайте BACKUP_PI_SSH в .env (как вы заходите на Pi, например pi@192.168.1.10) — README, шаг 6." ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1" } tc() { docker compose -f "$(native_path "$TEST_COMPOSE")" "$@"; } start_test_backup() { if [ -z "$(tc ps -q backup 2>/dev/null)" ]; then echo "Запускаю контейнер backup тест-клона…" tc up -d --build backup fi } fs() { # fs <аргументы fs-backup…> if [ "$TARGET" = test ]; then start_test_backup tc exec -T backup fs-backup "$@" else pi "docker compose exec -T backup fs-backup $*" fi } cmd_pull() { local snap=latest repo=local while [ $# -gt 0 ]; do case "$1" in --repo) repo="${2:?--repo требует значение}"; shift ;; *) snap="$1" ;; esac shift done mkdir -p "$PROJECT_DIR/backups" local info id stamp local_file partial expected actual info="$(fs info "$snap" --repo "$repo" | tail -n 1)" id="$(printf '%s' "$info" | awk '{ print $1 }')" stamp="$(printf '%s' "$info" | awk '{ print $2 }')" [ -n "$id" ] && [ -n "$stamp" ] || die "неожиданный ответ fs-backup info: '$info'" local_file="$PROJECT_DIR/backups/fs_${stamp}_${id}.tar" if [ -f "$local_file" ]; then echo "Уже скачан: $local_file"; return 0; fi partial="$local_file.part" echo "Снимок $id ($repo) -> $local_file" if [ "$TARGET" = test ]; then start_test_backup local tmp="/tmp/fs-backup/export-$id.tar" expected="$(tc exec -T backup sh -c "fs-backup export $id --repo $repo > $tmp && sha256sum $tmp" | tail -n 1 | cut -d' ' -f1)" tc cp "backup:$tmp" "$(native_path "$partial")" || { tc exec -T backup rm -f "$tmp"; die "копирование из контейнера не удалось"; } tc exec -T backup rm -f "$tmp" else local remote="fs-export-$id.tar" expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)" scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; } pi "rm -f ~/$remote" fi actual="$(sha256 "$partial")" if [ "$expected" != "$actual" ]; then rm -f "$partial" die "контрольная сумма не совпала (ожидалась $expected, получена $actual) — файл удалён, повторите pull." fi mv "$partial" "$local_file" tar -tf "$local_file" | grep -qx 'forbidden_stars.db' || die "в архиве нет forbidden_stars.db: $local_file" echo "OK: $local_file ($(du -h "$local_file" | cut -f1), $(tar -tf "$local_file" | grep -vc '/$') файлов, sha256 сверена)" } cmd_restore_test() { local file="${1:-}" [ -n "$file" ] || die "укажите архив: scripts/fs-backup.sh restore-test backups/fs_....tar" [ -f "$file" ] || die "файл не найден: $file" local in_container=/import/restore-test.archive # tar или tar.gz — формат fs-backup определит сам echo "Учебное восстановление $file в ЛОКАЛЬНЫЙ ТЕСТ-КЛОН (прод не затрагивается)." start_test_backup tc stop app tc cp "$(native_path "$file")" "backup:$in_container" local rc=0 tc exec -T backup fs-backup import "$in_container" --yes || rc=$? tc exec -T backup rm -f "$in_container" [ "$rc" -eq 0 ] || die "импорт не удался (код $rc) — данные тест-клона не изменены." tc up -d app echo "Готово: тест-клон работает на восстановленных данных." echo " На https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (или ./run.sh при APP_ENV=test)" } cmd="${1:-help}" [ $# -eq 0 ] || shift case "$cmd" in status) fs status ;; list) fs list "${1:-local}" ;; now) fs run "$@" ;; verify) fs verify ;; pull) cmd_pull "$@" ;; restore-test) cmd_restore_test "$@" ;; *) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' ;; esac