Настройка бэкапа (#64) #66
@@ -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 <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 { } }
|
||||
}
|
||||
@@ -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 [<id>|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 <shell-команда>: выполнить на 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
|
||||
Reference in New Issue
Block a user