From d764ff86d33def00d46b055997e708c3a56d951e Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 01:12:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D1=8D=D0=BA=D0=B0=D0=BF:=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=20=D0=9F=D0=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/fs-backup.ps1 (ASCII, Windows PowerShell 5.1) и scripts/fs-backup.sh (Linux/macOS/ Git Bash) — одинаковые команды к контейнеру backup прода на Pi по SSH или к локальному тест-клону (-Target test / --test): status, list, now [--tag], verify; pull — export в файл на Pi, scp в backups/, сверка sha256 и проверка содержимого tar (бинарные данные не идут через пайпы PowerShell — они их портят); restore-test — учебное восстановление архива (в т.ч. старого fs_*.tar.gz) в тест-клон. Настройки BACKUP_PI_SSH / BACKUP_PI_DIR из .env, переменная окружения важнее. Вызовы нативных команд устойчивы к перенаправлению stderr в PS 5.1. #64 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013jBxs9nBCk5nBdTLzcGz91 --- scripts/fs-backup.ps1 | 226 ++++++++++++++++++++++++++++++++++++++++++ scripts/fs-backup.sh | 138 ++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 scripts/fs-backup.ps1 create mode 100644 scripts/fs-backup.sh diff --git a/scripts/fs-backup.ps1 b/scripts/fs-backup.ps1 new file mode 100644 index 0000000..6af09d2 --- /dev/null +++ b/scripts/fs-backup.ps1 @@ -0,0 +1,226 @@ +# Forbidden Stars backups from the PC. Talks to the `backup` container of the prod on the Pi +# over SSH, or (with -Target test) to the local test clone (docker-compose.test.yml). +# Step-by-step guide: deploy/backup/README.md +# +# .\scripts\fs-backup.ps1 status backup state on the Pi +# .\scripts\fs-backup.ps1 list [-Repo vps] snapshot history +# .\scripts\fs-backup.ps1 now [-Tag before-update] make a snapshot right now +# .\scripts\fs-backup.ps1 verify check data integrity in the repositories +# .\scripts\fs-backup.ps1 pull [-Snapshot ] [-Repo vps] +# download a snapshot to backups\ (sha256 checked) +# .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar +# practice restore into the local test clone +# add -Target test to run status/list/now/verify/pull against the local test clone +# +# Settings come from the root .env (an environment variable with the same name wins): +# BACKUP_PI_SSH how to reach the Pi over SSH, e.g. pi@192.168.1.10 (or a Host alias) +# BACKUP_PI_DIR folder on the Pi with docker-compose.yml and .env (default ~/forbidden-stars) +# +# Keep this file ASCII-only: Windows PowerShell 5.1 breaks on non-ASCII without a BOM. +param( + [Parameter(Position = 0)] + [ValidateSet("status", "list", "now", "verify", "pull", "restore-test", "help")] + [string]$Command = "help", + [string]$Snapshot = "latest", + [ValidateSet("local", "vps")] + [string]$Repo = "local", + [string]$Tag = "", + [string]$File = "", + [ValidateSet("pi", "test")] + [string]$Target = "pi" +) +$ErrorActionPreference = "Stop" +# Native tools (ssh, scp, docker) write progress and warnings to stderr. Under "Stop" with a +# redirected stderr, PowerShell 5.1 turns those lines into terminating errors - so native calls +# run under "Continue" and success is judged by $LASTEXITCODE only. +$root = Split-Path -Parent $PSScriptRoot +$envFile = Join-Path $root ".env" +$testCompose = Join-Path $root "docker-compose.test.yml" + +# Read a key: environment variable first, then the root .env (last assignment wins). +function Get-Setting([string]$name, [string]$default) { + $fromEnv = [Environment]::GetEnvironmentVariable($name) + if ($fromEnv) { return $fromEnv } + $val = $default + if (Test-Path $envFile) { + foreach ($line in Get-Content $envFile) { + if ($line -match "^\s*$name\s*=\s*([^#\s]+)") { $val = $matches[1].Trim().Trim('"') } + } + } + return $val +} + +function Fail([string]$msg) { + Write-Host $msg -ForegroundColor Red + exit 1 +} + +function Show-Help { + Get-Content $PSCommandPath -TotalCount 19 | ForEach-Object { $_ -replace '^# ?', '' } +} + +$piSsh = Get-Setting "BACKUP_PI_SSH" "" +$piDir = Get-Setting "BACKUP_PI_DIR" "~/forbidden-stars" + +# Run a shell command on the Pi inside the prod compose folder. Output goes to the console +# unless the caller captures it. +function Invoke-Pi([string]$shellCmd) { + if (-not $piSsh) { + Fail "Set BACKUP_PI_SSH in .env (how you ssh to the Pi, e.g. pi@192.168.1.10). See deploy/backup/README.md, step 6." + } + $ErrorActionPreference = "Continue" + & ssh -o ConnectTimeout=15 $piSsh "cd $piDir && $shellCmd" +} + +function Invoke-Scp([string]$from, [string]$to) { + $ErrorActionPreference = "Continue" + & scp -o ConnectTimeout=15 $from $to +} + +function Invoke-TestCompose([string[]]$composeArgs) { + $ErrorActionPreference = "Continue" + & docker compose -f $testCompose @composeArgs +} + +# Make sure the backup container of the test clone is running (build it if needed). +function Start-TestBackup { + $id = (Invoke-TestCompose @("ps", "-q", "backup")) | Select-Object -First 1 + if (-not $id) { + Write-Host "Starting the backup container of the test clone..." -ForegroundColor Cyan + Invoke-TestCompose @("up", "-d", "--build", "backup") | Out-Host + if ($LASTEXITCODE -ne 0) { Fail "Could not start the test clone backup container." } + } +} + +# Run fs-backup with arguments on the chosen target; output goes to the console. +function Invoke-FsBackup([string[]]$fsArgs) { + if ($Target -eq "test") { + Start-TestBackup + Invoke-TestCompose (@("exec", "-T", "backup", "fs-backup") + $fsArgs) + } else { + Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " ")) + } +} + +function Assert-LastExit([string]$what) { + if ($LASTEXITCODE -ne 0) { Fail "$what failed (exit code $LASTEXITCODE)." } +} + +# ---------------------------------------------------------------------------- pull +function Invoke-Pull { + $backupsDir = Join-Path $root "backups" + New-Item -ItemType Directory -Force $backupsDir | Out-Null + + # Resolve the snapshot: short id + time -> file name fs__.tar + $info = Invoke-FsBackup @("info", $Snapshot, "--repo", $Repo) | Select-Object -Last 1 + Assert-LastExit "Snapshot lookup" + $parts = "$info".Trim() -split "\s+" + if ($parts.Count -lt 2) { Fail "Unexpected answer from fs-backup info: '$info'" } + $id = $parts[0] + $name = "fs_$($parts[1])_$id.tar" + $local = Join-Path $backupsDir $name + if (Test-Path $local) { + Write-Host "Already downloaded: $local" -ForegroundColor Yellow + return + } + Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan + + $partial = "$local.part" + if ($Target -eq "test") { + Start-TestBackup + $tmp = "/tmp/fs-backup/export-$id.tar" + $hashLine = Invoke-TestCompose @("exec", "-T", "backup", "sh", "-c", + "fs-backup export $id --repo $Repo > $tmp && sha256sum $tmp") | Select-Object -Last 1 + Assert-LastExit "Export" + try { + Invoke-TestCompose @("cp", "backup:$tmp", $partial) + Assert-LastExit "Copy from the container" + } finally { + Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $tmp) | Out-Null + } + } else { + # Export into a file in the Pi user's home (binary data never passes through + # PowerShell pipes - they would corrupt it), then scp it and compare sha256. + $remote = "fs-export-$id.tar" + $hashLine = Invoke-Pi "docker compose exec -T backup fs-backup export $id --repo $Repo > ~/$remote && sha256sum ~/$remote" | + Select-Object -Last 1 + Assert-LastExit "Export on the Pi" + try { + Invoke-Scp "${piSsh}:$remote" $partial + Assert-LastExit "scp" + } finally { + Invoke-Pi "rm -f ~/$remote" + } + } + + $expected = ("$hashLine".Trim() -split "\s+")[0].ToLower() + $actual = (Get-FileHash -Algorithm SHA256 $partial).Hash.ToLower() + if ($expected -ne $actual) { + Remove-Item $partial -Force + Fail "Checksum mismatch (expected $expected, got $actual) - the download is removed, run pull again." + } + Move-Item $partial $local + + $entries = & { + $ErrorActionPreference = "Continue" + & "$env:SystemRoot\System32\tar.exe" -tf $local + } + Assert-LastExit "tar listing" + if (-not ($entries -contains "forbidden_stars.db")) { Fail "The archive has no forbidden_stars.db: $local" } + $files = @($entries | Where-Object { $_ -notmatch '/$' }).Count + $sizeMb = [math]::Round((Get-Item $local).Length / 1MB, 1) + Write-Host "OK: $local ($sizeMb MB, $files files, sha256 verified)" -ForegroundColor Green +} + +# -------------------------------------------------------------------- restore-test +function Invoke-RestoreTest { + if (-not $File) { Fail "Specify the archive: -File backups\fs_....tar" } + if (-not (Test-Path $File -PathType Leaf)) { Fail "File not found: $File" } + $full = (Resolve-Path $File).Path + $inContainer = "/import/restore-test.archive" # tar or tar.gz: fs-backup detects the format itself + + Write-Host "Practice restore of $full into the LOCAL TEST CLONE (prod is not touched)." -ForegroundColor Cyan + Start-TestBackup + Invoke-TestCompose @("stop", "app") + Assert-LastExit "Stopping the test app" + Invoke-TestCompose @("cp", $full, "backup:$inContainer") + Assert-LastExit "Copy into the container" + try { + Invoke-TestCompose @("exec", "-T", "backup", "fs-backup", "import", $inContainer, "--yes") + $importExit = $LASTEXITCODE + } finally { + Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $inContainer) | Out-Null + } + if ($importExit -ne 0) { Fail "Import failed (exit code $importExit). The test clone data was not changed." } + + Invoke-TestCompose @("up", "-d", "app") + Assert-LastExit "Starting the test app" + Write-Host "Done. The test clone now runs on the restored data." -ForegroundColor Green + Write-Host " See it on https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (or .\run.ps1 with APP_ENV=test)" + Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f app" +} + +# ---------------------------------------------------------------------------- main +$prevEncoding = $null +try { + $prevEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # container messages are UTF-8 +} catch { } +try { + switch ($Command) { + "status" { Invoke-FsBackup @("status"); Assert-LastExit "status" } + "list" { Invoke-FsBackup @("list", $Repo); Assert-LastExit "list" } + "now" { + $runArgs = @("run") + if ($Tag) { $runArgs += @("--tag", $Tag) } + Invoke-FsBackup $runArgs + Assert-LastExit "Backup" + } + "verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" } + "pull" { Invoke-Pull } + "restore-test" { Invoke-RestoreTest } + default { Show-Help } + } +} finally { + if ($prevEncoding) { try { [Console]::OutputEncoding = $prevEncoding } catch { } } +} diff --git a/scripts/fs-backup.sh b/scripts/fs-backup.sh new file mode 100644 index 0000000..e8a61f5 --- /dev/null +++ b/scripts/fs-backup.sh @@ -0,0 +1,138 @@ +#!/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