# 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 { } } }