Files
ForbiddenStarsApp/scripts/fs-backup.ps1
T
NotBigGhostandClaude Opus 5 d764ff86d3 Бэкап: скрипты управления с ПК
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jBxs9nBCk5nBdTLzcGz91
2026-09-14 01:12:41 +03:00

227 lines
9.7 KiB
PowerShell

# 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 <id>] [-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_<yyyyMMdd_HHmm>_<id>.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 { } }
}