fix(ps): zbackup explicit target + robust DATABASE_URL parsing + real pg_dump error reporting (#10)

* fix(ps): zbackup explicit target + robust DATABASE_URL parsing; ssh-stderr deploy fix

Restores parked, previously-uncommitted PowerShell improvements:

- zbackup / zbackup_and_sync require an explicit target: bare invocation
  now prints usage instead of quietly backing up everything; 'all' does
  what bare used to (matching zdeploy). setup_backup_schedule.ps1 passes
  'all' to the scheduled task; both tolerate switch-style args.
- zbackup parses more DATABASE_URL styles: strips surrounding quotes
  (Prisma convention), accepts postgres:// and postgresql+driver://
  schemes, and treats the port as optional (defaults to 5432).
- Invoke-Ec2Step survives ssh stderr warnings: under ErrorActionPreference
  'Stop', PS 5.1 turns any native stderr line (e.g. Docker's COMPOSE_BAKE
  deprecation notice) into a terminating NativeCommandError, aborting a
  deploy that actually succeeded. Drop to Continue locally and flatten
  stderr so only the real exit code decides success.

* fix(ps): zbackup reports the real pg_dump failure, not "No DATABASE_URL"

Mirror of the bash fix. Invoke-LocalPgDump now owns all its messaging
(caller just captures success) and distinguishes the cases:

- no .env / no DATABASE_URL -> calm "No local DATABASE_URL".
- database not reachable (connection refused / could not connect / DNS /
  timeout) -> calm "Local database not running at host:port" - a stopped
  dev DB is a normal state.
- any other failure (version mismatch, auth, missing db) -> the loud,
  full pg_dump error plus the host:port/db it tried, instead of a bare
  "pg_dump failed (exit N)" followed by a misleading "No DATABASE_URL".

Captures pg_dump stderr (was 2>$null); drops ErrorActionPreference to
Continue locally so PS 5.1 doesn't turn that stderr into a terminating
NativeCommandError under the script's 'Stop' setting.
This commit is contained in:
kellymichels 2026-07-25 22:16:48 -05:00 committed by GitHub
parent 5844fc1614
commit 50b7c81736
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 87 additions and 23 deletions

View File

@ -49,6 +49,16 @@ Notable changes to the Evomedia.net Token Savers.
grew up on per-project switches. grew up on per-project switches.
### Changed ### Changed
- **`zbackup` / `zbackup_and_sync` require an explicit target** — running
them bare now shows usage instead of quietly backing up every project;
`all` does what bare invocation used to (matching `zdeploy`). The
scheduled task created by `setup_backup_schedule.ps1` passes `all` —
re-run it if your task was registered before this change.
- **`zbackup` parses more `DATABASE_URL` styles** — double/single-quoted
values (Prisma convention), `postgres://` and `postgresql+driver://`
schemes, and URLs without an explicit port (defaults to 5432) all work;
previously these skipped the Postgres dump with "Could not parse
DATABASE_URL".
- **`zkill` / port cleanup kills the whole process tree** — listeners on a - **`zkill` / port cleanup kills the whole process tree** — listeners on a
project's port are now terminated children-first. Auto-reloading servers project's port are now terminated children-first. Auto-reloading servers
(uvicorn/watchfiles, nodemon) spawn workers that inherit the listening (uvicorn/watchfiles, nodemon) spawn workers that inherit the listening

View File

@ -109,7 +109,16 @@ function Invoke-Ec2Step {
) )
$cfg = Get-ZConfig $cfg = Get-ZConfig
Write-Host " >> $Label" -ForegroundColor DarkCyan Write-Host " >> $Label" -ForegroundColor DarkCyan
ssh -o StrictHostKeyChecking=no -i $cfg.ec2.pemKey (Get-Ec2Target) $Bash # ssh can emit warnings on stderr (e.g. Docker's "COMPOSE_BAKE is
# deprecated" notice during a compose build). The deploy runs under
# ErrorActionPreference='Stop', and PowerShell 5.1 turns any native stderr
# line into a terminating NativeCommandError — aborting the deploy before we
# ever read the real exit code, even though the remote step succeeded. Drop
# to Continue locally (function-scoped, auto-reverts) and flatten stderr
# into normal output, so only the actual exit status decides success.
$ErrorActionPreference = 'Continue'
ssh -o StrictHostKeyChecking=no -i $cfg.ec2.pemKey (Get-Ec2Target) $Bash 2>&1 |
ForEach-Object { "$_" }
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
$msg = "Remote step failed: '$Label' (exit $LASTEXITCODE)." $msg = "Remote step failed: '$Label' (exit $LASTEXITCODE)."
if ($FailHint) { $msg += " $FailHint" } if ($FailHint) { $msg += " $FailHint" }

View File

@ -46,7 +46,7 @@ if ($existingTask) {
$trigger = New-ScheduledTaskTrigger -Daily -At "02:00" $trigger = New-ScheduledTaskTrigger -Daily -At "02:00"
$action = New-ScheduledTaskAction ` $action = New-ScheduledTaskAction `
-Execute "powershell.exe" ` -Execute "powershell.exe" `
-Argument "-ExecutionPolicy Bypass -NoProfile -File `"$ScriptPath`"" -Argument "-ExecutionPolicy Bypass -NoProfile -File `"$ScriptPath`" all"
$settings = New-ScheduledTaskSettingsSet ` $settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries ` -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries ` -DontStopIfGoingOnBatteries `

View File

@ -6,8 +6,8 @@
# project's .env has a DATABASE_URL) into the backups folder. # project's .env has a DATABASE_URL) into the backups folder.
# #
# Usage: # Usage:
# zbackup # every project in zconfig.json + this scripts folder
# zbackup <project> [<project> ...] # zbackup <project> [<project> ...]
# zbackup all # every project in zconfig.json + this scripts folder
# zbackup scripts # just this scripts folder ('scripts' is a reserved word) # zbackup scripts # just this scripts folder ('scripts' is a reserved word)
# zbackup pyapp -Tag "pre-migration" # zbackup pyapp -Tag "pre-migration"
# #
@ -26,8 +26,22 @@ Start-ZTracking
$cfg = Get-ZConfig $cfg = Get-ZConfig
$ProjectsBackupRoot = $cfg.paths.backupsLocal $ProjectsBackupRoot = $cfg.paths.backupsLocal
$includeScripts = $false # Tolerate switch-style args (zbackup -myproject) from muscle memory.
$Projects = @($Projects | ForEach-Object { $_.TrimStart('-') })
# No args shows usage instead of quietly backing up everything — 'all' is
# explicit, matching zdeploy and the other z-commands.
if ($Projects.Count -eq 0) { if ($Projects.Count -eq 0) {
$keys = (Get-ZProjectKeys) -join ', '
Write-Host ""
Write-Host "Usage: zbackup <project> [<project> ...] | all | scripts [-Tag `"label`"]" -ForegroundColor Yellow
Write-Host " Projects in zconfig.json: $keys" -ForegroundColor Gray
Write-Host " 'all' backs up every project plus this scripts folder; 'scripts' just this folder." -ForegroundColor Gray
Stop-ZTracking; exit 1
}
$includeScripts = $false
if ($Projects -contains 'all') {
$Projects = @(Get-ZProjectKeys) $Projects = @(Get-ZProjectKeys)
$includeScripts = $true $includeScripts = $true
} elseif ($Projects -contains 'scripts') { } elseif ($Projects -contains 'scripts') {
@ -53,19 +67,29 @@ function Ensure-BackupDir {
# Dump the project's Postgres database if its .env declares a DATABASE_URL. # Dump the project's Postgres database if its .env declares a DATABASE_URL.
# Checks <root>\.env, then <root>\backend\.env (frontend/backend split projects). # Checks <root>\.env, then <root>\backend\.env (frontend/backend split projects).
# Prints its own status line for every outcome; returns $true (dumped) or $false.
# A not-running database (connection refused) is a calm, expected skip; a real
# failure (version mismatch, auth, missing db) prints the loud pg_dump error.
function Invoke-LocalPgDump { function Invoke-LocalPgDump {
param([string]$Root, [string]$OutPath) param([string]$Root, [string]$OutPath)
$envFile = Join-Path $Root ".env" $envFile = Join-Path $Root ".env"
if (-not (Test-Path -LiteralPath $envFile)) { $envFile = Join-Path $Root "backend\.env" } if (-not (Test-Path -LiteralPath $envFile)) { $envFile = Join-Path $Root "backend\.env" }
if (-not (Test-Path -LiteralPath $envFile)) { return $false } if (-not (Test-Path -LiteralPath $envFile)) {
Write-Host " No local DATABASE_URL - source-only backup." -ForegroundColor DarkGray; return $false
}
$dbLine = @(Get-Content -LiteralPath $envFile | Where-Object { $_ -match "^DATABASE_URL=" } | Select-Object -First 1) $dbLine = @(Get-Content -LiteralPath $envFile | Where-Object { $_ -match "^DATABASE_URL=" } | Select-Object -First 1)
if ($dbLine.Count -eq 0) { return $false } if ($dbLine.Count -eq 0) {
Write-Host " No local DATABASE_URL - source-only backup." -ForegroundColor DarkGray; return $false
}
$dbUrl = ($dbLine[0] -replace "^DATABASE_URL=", "").Trim() # Strip surrounding quotes (Prisma-style .env values are double-quoted),
$dbUrl = $dbUrl -replace '^postgresql\+[^:]+://', 'postgresql://' # accept postgres:// and postgresql+driver:// schemes, and treat the
# port as optional (Postgres default 5432).
$dbUrl = ($dbLine[0] -replace "^DATABASE_URL=", "").Trim().Trim('"').Trim("'")
$dbUrl = $dbUrl -replace '^postgres(ql)?(\+[^:]+)?://', 'postgresql://'
$rx = [regex]::Match( $rx = [regex]::Match(
$dbUrl, $dbUrl,
'^postgresql://(?<user>[^:]+):(?<pass>[^@]+)@(?<host>[^:]+):(?<port>\d+)/(?<db>[^?]+)' '^postgresql://(?<user>[^:@/]+):(?<pass>[^@]+)@(?<host>[^:/?]+)(:(?<port>\d+))?/(?<db>[^?\s]+)'
) )
if (-not $rx.Success) { if (-not $rx.Success) {
Write-Host ' Could not parse DATABASE_URL - skipping PG backup' -ForegroundColor Red Write-Host ' Could not parse DATABASE_URL - skipping PG backup' -ForegroundColor Red
@ -74,7 +98,7 @@ function Invoke-LocalPgDump {
$env:PGPASSWORD = [Uri]::UnescapeDataString($rx.Groups['pass'].Value) $env:PGPASSWORD = [Uri]::UnescapeDataString($rx.Groups['pass'].Value)
$pgUser = $rx.Groups['user'].Value $pgUser = $rx.Groups['user'].Value
$pgHost = $rx.Groups['host'].Value $pgHost = $rx.Groups['host'].Value
$pgPort = $rx.Groups['port'].Value $pgPort = if ($rx.Groups['port'].Success) { $rx.Groups['port'].Value } else { '5432' }
$pgDb = $rx.Groups['db'].Value $pgDb = $rx.Groups['db'].Value
$pgDump = "pg_dump" $pgDump = "pg_dump"
@ -87,16 +111,31 @@ function Invoke-LocalPgDump {
foreach ($candidate in $pgBinPaths) { foreach ($candidate in $pgBinPaths) {
if (Test-Path $candidate) { $pgDump = $candidate; break } if (Test-Path $candidate) { $pgDump = $candidate; break }
} }
& $pgDump -h $pgHost -p $pgPort -U $pgUser -d $pgDb -F p -f $OutPath 2>$null # Capture stderr so a failure is diagnosable (version mismatch, unreachable
# host, auth) instead of a bare "pg_dump failed". PS 5.1 turns native stderr
# into a terminating NativeCommandError under 'Stop', so drop to Continue
# locally (function-scoped, auto-reverts) while running pg_dump.
$ErrorActionPreference = 'Continue'
$errOut = & $pgDump -h $pgHost -p $pgPort -U $pgUser -d $pgDb -F p -f $OutPath 2>&1
$ok = ($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $OutPath) $ok = ($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $OutPath)
Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue
if ($ok) { if ($ok) {
$size = [math]::Round((Get-Item $OutPath).Length / 1KB, 1) $size = [math]::Round((Get-Item $OutPath).Length / 1KB, 1)
Write-Host " PostgreSQL dump: ${size} KB" -ForegroundColor Green Write-Host " PostgreSQL dump: ${size} KB" -ForegroundColor Green
} else { return $true
Write-Host " pg_dump failed (exit $LASTEXITCODE)" -ForegroundColor Red
} }
return $ok $errText = ($errOut | Out-String).Trim()
# Not reachable (usually just not running locally) is expected - stay calm.
if ($errText -match '(?i)connection refused|could not connect|no route to host|could not translate host|timeout expired') {
Write-Host " Local database not running at ${pgHost}:${pgPort} - source-only backup." -ForegroundColor DarkGray
return $false
}
# A real failure (version mismatch, auth, missing db) - show the reason.
Write-Host " pg_dump failed (${pgHost}:${pgPort}/${pgDb} as ${pgUser}):" -ForegroundColor Red
foreach ($line in ($errText -split '\r?\n' | Where-Object { $_.Trim() -ne '' })) {
Write-Host " $line" -ForegroundColor Red
}
return $false
} }
function Invoke-ProjectBackup { function Invoke-ProjectBackup {
@ -124,8 +163,6 @@ function Invoke-ProjectBackup {
$dbDumpPath = Join-Path $dumpDir "database_pg.sql" $dbDumpPath = Join-Path $dumpDir "database_pg.sql"
if (Invoke-LocalPgDump -Root $root -OutPath $dbDumpPath) { if (Invoke-LocalPgDump -Root $root -OutPath $dbDumpPath) {
$extraFiles += $dbDumpPath $extraFiles += $dbDumpPath
} else {
Write-Host " No local DATABASE_URL - source-only backup." -ForegroundColor DarkGray
} }
Write-Host " [2/3] Archiving source..." -ForegroundColor Yellow Write-Host " [2/3] Archiving source..." -ForegroundColor Yellow

View File

@ -5,11 +5,11 @@
# zbackup_and_sync.ps1 — run backups, then sync the backups folder offsite. # zbackup_and_sync.ps1 — run backups, then sync the backups folder offsite.
# #
# Usage: # Usage:
# zbackup_and_sync.ps1 # backup everything + sync
# zbackup_and_sync.ps1 <project> [<project> ...] # zbackup_and_sync.ps1 <project> [<project> ...]
# zbackup_and_sync.ps1 all # backup everything + sync
# #
# Scheduled Task example (see setup_backup_schedule.ps1): # Scheduled Task example (see setup_backup_schedule.ps1):
# powershell -ExecutionPolicy Bypass -NoProfile -File "<scriptsRoot>\zbackup_and_sync.ps1" # powershell -ExecutionPolicy Bypass -NoProfile -File "<scriptsRoot>\zbackup_and_sync.ps1" all
param( param(
[Parameter(Position = 0, ValueFromRemainingArguments = $true)] [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
@ -21,6 +21,18 @@ $ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Definition
. (Join-Path $ScriptRoot "ZHelpers.ps1") . (Join-Path $ScriptRoot "ZHelpers.ps1")
Start-ZTracking Start-ZTracking
# Tolerate switch-style args from muscle memory; require an explicit target
# ('all' included) — same convention as zbackup/zdeploy.
$Projects = @($Projects | ForEach-Object { $_.TrimStart('-') })
if ($Projects.Count -eq 0) {
$keys = (Get-ZProjectKeys) -join ', '
Write-Host ""
Write-Host "Usage: zbackup_and_sync <project> [<project> ...] | all" -ForegroundColor Yellow
Write-Host " Projects in zconfig.json: $keys" -ForegroundColor Gray
Write-Host " 'all' backs up every project plus the scripts folder, then syncs offsite." -ForegroundColor Gray
Stop-ZTracking; exit 1
}
Write-Host "" Write-Host ""
Write-Host "============================================" -ForegroundColor Cyan Write-Host "============================================" -ForegroundColor Cyan
Write-Host " Backup & Sync - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan Write-Host " Backup & Sync - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan
@ -29,11 +41,7 @@ Write-Host ""
Write-Host "[1/2] Running backups..." -ForegroundColor Yellow Write-Host "[1/2] Running backups..." -ForegroundColor Yellow
$backupPath = Join-Path $ScriptRoot "zbackup.ps1" $backupPath = Join-Path $ScriptRoot "zbackup.ps1"
if ($Projects.Count -gt 0) { & powershell -NoProfile -File $backupPath @Projects
& powershell -NoProfile -File $backupPath @Projects
} else {
& powershell -NoProfile -File $backupPath
}
$backupExitCode = $LASTEXITCODE $backupExitCode = $LASTEXITCODE
if ($backupExitCode -ne 0) { if ($backupExitCode -ne 0) {