Files
siftlode/scripts/restore.ps1
T
peter 14f1c97c0a fix(e12): a bare up -d picks the wrong compose file, and recreates the database
Round four, on round three's diff. 47 -> 34 -> 15 -> 7 raw findings.

**The printed restart command could strand the restored data.** Round three replaced
`start` with `up -d` because `start` ignores a newly pinned tag — but printed it bare. In
the git-clone layout BOTH compose files exist, so the scripts' COMPOSE_FILE fallback never
fires and a bare `docker compose up -d` reads `docker-compose.yml`: different volume names,
locally built images, no IMAGE_TAG at all. `stop` and `exec` had been fine there (they
resolve running containers by project and service), but `up -d` RECREATES — so the operator
pastes the hint straight after a successful restore and gets `db` on a new empty volume and
a first-run setup wizard, with their data in an orphaned volume. Verified: the two files
resolve `siftlode_pgdata` vs `pgdata` and different images.

The scripts no longer print a command that recreates from a file they did not resolve. They
name the compose file when they resolved one, and otherwise say to start it the way the
operator normally does — with the caveats on their own lines rather than in a parenthetical
at the end of a very long one.

**The reset flag is now decided where the live hash is already parsed.** Round three cleared
it in the token effect, which only runs when a NEW verify token arrives — so the back
button, or re-pasting an already-consumed verify link, still left the banner promising a
recovery whose token was gone. Setting it from the hash on every fragment change is both
smaller and closes those.

Also: the doc said `start` "still carries the old tag" four lines after "pin the older
IMAGE_TAG", where "old" then reads as the tag being rolled back TO — it now reuses the
scripts' unambiguous wording; the Windows recipe removes a partially-copied dump before
throwing, so a mid-stream `cp` failure cannot leave a truncated file under the final name;
and `$WarningPreference` is pinned beside `$ErrorActionPreference` so an inherited
SilentlyContinue cannot drop the half-restored warning, with the comment corrected to say
what the warning stream actually is.

Gate green: vitest 685, pytest 950.
2026-09-05 04:31:13 +02:00

73 lines
4.5 KiB
PowerShell

# Restores a Siftlode Postgres dump created by backup.ps1 into the running db container.
# Usage: .\scripts\restore.ps1 backups\siftlode-YYYYMMDD-HHMMSS.dump
#
# Restoring the database does NOT restore the secrets that decrypt it. If this dump is from an
# instance with a different TOKEN_ENCRYPTION_KEY than the current .env, the encrypted sysconfig
# values and refresh tokens will not be readable. See docs/self-hosting.md § Backups.
param([Parameter(Mandatory = $true)][string]$DumpFile)
$ErrorActionPreference = "Stop"
$WarningPreference = "Continue"
# See backup.ps1: the dump travels with `docker compose cp`, never through the PowerShell pipeline,
# which corrupts binary on 5.1 and cannot express `-Encoding Byte` at all on 7.
$remote = "/tmp/siftlode-restore.dump"
# See backup.ps1: on Windows PowerShell 5.1 a redirected native stderr is a TERMINATING error under
# $ErrorActionPreference = "Stop", so without the catch a cleanup step could kill the script after
# the restore already succeeded — and the obvious next move, re-running it, wipes the database again.
function Remove-RemoteDump($path) {
try { docker compose exec -T db rm -f $path 2>&1 | Out-Null } catch { }
}
if (-not $env:COMPOSE_FILE -and -not (Test-Path "docker-compose.yml") -and (Test-Path "docker-compose.selfhost.yml")) {
$env:COMPOSE_FILE = "docker-compose.selfhost.yml"
}
$user = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "siftlode" }
$dbname = if ($env:POSTGRES_DB) { $env:POSTGRES_DB } else { "siftlode" }
if (-not (Test-Path $DumpFile)) { throw "Dump file not found: $DumpFile" }
docker compose cp $DumpFile "db:$remote"
$cpCode = $LASTEXITCODE
if ($cpCode -ne 0) { throw "RESTORE FAILED — could not copy the dump into the container (exit $cpCode)." }
# Stop the app before dropping its tables. `--clean` DROPs each object in its own transaction, and a
# running api (with its scheduler) holds locks that make those DROPs queue behind it — abort the wait
# and the database is left half-dropped. It also keeps the app from writing into a schema that is
# only partly back.
#
# It is NOT restarted automatically, on purpose. Every start runs `alembic upgrade head`, so on the
# documented rollback path (restore a pre-upgrade dump, THEN pin the older IMAGE_TAG) an automatic
# restart would re-apply the very migrations the restore just undid. On a FAILED restore it would
# put the app back on a half-restored schema, which is the second thing the stop exists to prevent.
# The operator starts it when they are ready — with `up -d`, not `start`: `start` relaunches the
# EXISTING container, so it ignores a newly pinned IMAGE_TAG and comes back on the image being
# rolled back FROM.
docker compose stop api worker
$stopCode = $LASTEXITCODE
if ($stopCode -ne 0) { throw "Could not stop api/worker (exit $stopCode) — refusing to restore while the app may still be writing." }
docker compose exec -T db pg_restore -U $user -d $dbname --clean --if-exists $remote
$restoreCode = $LASTEXITCODE
Remove-RemoteDump $remote
# pg_restore exits non-zero for ignorable warnings too (--clean --if-exists on a fresh database
# reports every missing object it tried to drop), so a non-zero code is reported as "check this",
# not swallowed and not dressed up as success.
if ($restoreCode -ne 0) {
# Write-Warning, not Write-Output: `>` redirects only the SUCCESS stream, so on stdout this
# explanation would be filed into the log while the bare exception stayed on screen. (The
# warning stream is its own stream, not the error stream — $WarningPreference is pinned at the
# top so an inherited "SilentlyContinue" cannot drop it.)
Write-Warning "api and worker are left STOPPED: the database may be half-restored. Fix the dump and re-run before starting them."
throw "pg_restore reported errors while restoring $DumpFile (exit $restoreCode) — read the output above before treating this instance as restored."
}
Write-Output "Restored $DumpFile"
$cf = if ($env:COMPOSE_FILE) { " -f $env:COMPOSE_FILE" } else { "" }
Write-Output "api and worker are stopped. Start them again the way you normally start Siftlode:"
Write-Output " docker compose$cf up -d"
Write-Output "Use the SAME compose file you started with — 'up -d' recreates from whatever file it reads,"
Write-Output "and a different one points at different volumes. Rolling a release back? Pin the older"
Write-Output "IMAGE_TAG in .env FIRST: every start re-runs the migrations, and 'start' would relaunch the"
Write-Output "existing container on the image you are rolling back FROM."