[CmdletBinding()] param( [switch]$Force, [switch]$SkipSiteCheck, [string]$Service, # When set, the next mail-api boot copies admin state (client_profiles, # allowed_emails, drafts) from the on-disk JSON files into the shared # postgres database, overwriting anything currently in admin_kv. After the # deploy completes the flag is automatically reset to 'auto' for subsequent # boots so we don't keep overwriting live data. [switch]$SeedAdminData, # Skip regenerating the legacy-clients-seed.json file. By default every # deploy refreshes it from data/legacy-{clients,onboarding,contracts}.json. # The mail-api merges this on boot, add-only — it never clobbers live data. [switch]$SkipLegacySeed ) # --------------------------------------------------------------------------- # UI helpers — colored output, step headers, timing # --------------------------------------------------------------------------- $script:UseColor = $Host.UI.SupportsVirtualTerminal -or [bool]$env:WT_SESSION -or [bool]$env:TERM_PROGRAM $script:ESC = [char]27 # Goodwalk brand palette + status colors (24-bit ANSI where supported) $script:CGreen = '38;2;122;170;122' # softened Goodwalk green for legibility on dark terminals $script:CYellow = '38;2;255;209;0' # #FFD100 $script:CCyan = '96' $script:CDim = '90' $script:CGrey = '37' $script:COk = '92' $script:CWarn = '93' $script:CErr = '91' function Format-Color { param([string]$Text, [string]$Code) if (-not $script:UseColor) { return $Text } return "$script:ESC[${Code}m$Text$script:ESC[0m" } function Show-DeployBanner { $border = '──────────────────────────────────────────────────────' # 54 chars $pad22 = ' ' * 22 Write-Host '' Write-Host (Format-Color " ╭$border╮" $script:CGreen) Write-Host -NoNewline (Format-Color ' │' $script:CGreen) Write-Host -NoNewline (Format-Color ' GoodWalk' $script:CYellow) Write-Host -NoNewline (Format-Color ' · production deploy' $script:CDim) Write-Host (Format-Color ($pad22 + '│') $script:CGreen) Write-Host (Format-Color " ╰$border╯" $script:CGreen) Write-Host '' } function Write-Info { param([string]$Text) Write-Host (Format-Color " $Text" $script:CDim) } function Write-Note { param([string]$Text) Write-Host (" " + (Format-Color '·' $script:CYellow) + " " + (Format-Color $Text $script:CGrey)) } function Write-Field { param([string]$Label, [string]$Value) $padded = $Label.PadRight(22) Write-Host -NoNewline (Format-Color " $padded" $script:CDim) Write-Host (Format-Color $Value $script:CGrey) } function Write-Section { param([string]$Text) Write-Host '' Write-Host (Format-Color "── $Text " $script:CCyan) } function Write-StepHeader { param([string]$Text) Write-Host '' Write-Host -NoNewline (Format-Color '▶ ' $script:CCyan) Write-Host (Format-Color $Text $script:CGrey) } function Write-Ok { param([string]$Text, [double]$Sec = -1) $suffix = if ($Sec -ge 0) { Format-Color (' ({0:N1}s)' -f $Sec) $script:CDim } else { '' } Write-Host (" " + (Format-Color '✓' $script:COk) + " " + (Format-Color $Text $script:CGrey) + $suffix) } function Write-Fail { param([string]$Text) Write-Host (" " + (Format-Color '✗' $script:CErr) + " " + (Format-Color $Text $script:CErr)) } function Write-WarnLine { param([string]$Text) Write-Host (" " + (Format-Color '!' $script:CWarn) + " " + (Format-Color $Text $script:CWarn)) } # --------------------------------------------------------------------------- # Goodwalk production deployment settings # Update these values before the first real deployment. # This script targets the main Svelte Goodwalk stack that uses the top-level # docker-compose.yml. It does not touch the legacy WordPress compose files. # --------------------------------------------------------------------------- $SshUser = 'root' $ServerHost = 'gw-prod' # Leave blank to use interactive password prompts instead of an SSH key. $SshKeyPath = '' $LocalProjectPath = Split-Path -Parent $MyInvocation.MyCommand.Path $SshConfigPath = Join-Path $LocalProjectPath 'ssh-config' $RemoteDeploymentPath = '/docker/goodwalk-svelte' $ComposeFileName = 'docker-compose.prod.yml' $DockerProjectName = 'goodwalk-svelte' $NginxConfigSource = 'nginx/goodwalk.co.nz.svelte.conf.example' $NginxConfigTarget = '/docker/nginx/conf.d/goodwalk.co.nz.conf' $NginxComposeFile = '/docker/nginx/docker-compose.yml' $NginxProjectName = 'nginx' # Host paths used for the maintenance page. The directory must be bind-mounted # into the shared nginx container at /var/www/maintenance:ro (see DEPLOYMENT.md). # The flag file lives in the existing conf.d bind mount; nginx ignores non-.conf # files, so it does not pollute the include glob. $MaintenanceHostDir = '/docker/nginx/maintenance' $MaintenanceFlagPath = '/docker/nginx/conf.d/maintenance.flag' # Optional deployment settings. $VerifyUrl = 'https://www.goodwalk.co.nz/api/health' $RemoteArchivePath = '/tmp/goodwalk-deploy.tgz' $RemoteHelperPath = '/tmp/goodwalk-deploy-remote.sh' $LocalRemoteHelperPath = Join-Path $LocalProjectPath 'scripts\deploy-remote.sh' $GeneratedHomepageContentPath = Join-Path $LocalProjectPath 'deploy-data\homepage-content.json' function Assert-NotBlank { param( [string]$Name, [string]$Value ) if ([string]::IsNullOrWhiteSpace($Value)) { throw "Required setting '$Name' is blank. Update deploy.ps1 before running it." } } function Assert-Command { param([string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required command '$Name' is not available in PATH." } } function Invoke-External { param( [string]$FilePath, [string[]]$Arguments ) & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { throw "Command failed: $FilePath $($Arguments -join ' ')" } } function Get-SshArgumentList { $args = @() if (-not [string]::IsNullOrWhiteSpace($SshConfigPath)) { $args += @('-F', $SshConfigPath) } if (-not [string]::IsNullOrWhiteSpace($SshKeyPath)) { $args += @('-i', $SshKeyPath) } return $args } function New-DeployArchive { param( [string]$ProjectPath ) $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $archivePath = Join-Path ([System.IO.Path]::GetTempPath()) "goodwalk-deploy-$timestamp.tgz" $excludeArgs = @( '--exclude=.git', '--exclude=node_modules', '--exclude=.svelte-kit', '--exclude=build', '--exclude=.env', '--exclude=.env.*', '--exclude=logs', '--exclude=mail-api/__pycache__', '--exclude=mail-api/*.pyc', '--exclude=migration-backups' ) if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } Push-Location $ProjectPath try { $tarArguments = @('-czf', $archivePath) + $excludeArgs + @('.') Invoke-External -FilePath 'tar' -Arguments $tarArguments } finally { Pop-Location } return $archivePath } function Export-HomepageContent { param( [string]$ProjectPath, [string]$OutputPath ) $scriptPath = Join-Path $ProjectPath 'scripts\export-homepage-content.mjs' if (-not (Test-Path -LiteralPath $scriptPath)) { throw "Homepage content export script not found: $scriptPath" } $resolverPath = Join-Path $ProjectPath 'scripts\sveltekit-resolver.mjs' Push-Location $ProjectPath try { Invoke-External -FilePath 'node' -Arguments @( '--experimental-strip-types', "--import=$(([uri]::new([System.IO.Path]::GetFullPath($resolverPath))).AbsoluteUri)", $scriptPath, $OutputPath ) } finally { Pop-Location } } function Build-LegacySeed { param( [string]$ProjectPath ) $scriptPath = Join-Path $ProjectPath 'scripts\build-legacy-seed.mjs' if (-not (Test-Path -LiteralPath $scriptPath)) { throw "Legacy seed builder not found: $scriptPath" } Push-Location $ProjectPath try { Invoke-External -FilePath 'node' -Arguments @($scriptPath) } finally { Pop-Location } $outputPath = Join-Path $ProjectPath 'mail-api\legacy-clients-seed.json' if (-not (Test-Path -LiteralPath $outputPath)) { throw "Legacy seed builder did not produce expected output: $outputPath" } return $outputPath } function New-UnixScriptCopy { param( [string]$SourcePath ) if (-not (Test-Path -LiteralPath $SourcePath)) { throw "Script not found: $SourcePath" } $tempFileName = 'goodwalk-deploy-helper-{0}.sh' -f ([System.Guid]::NewGuid().ToString('N')) $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) $tempFileName $content = [System.IO.File]::ReadAllText($SourcePath) $normalized = $content.Replace("`r`n", "`n").Replace("`r", "`n") $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($tempPath, $normalized, $utf8NoBom) return $tempPath } function Invoke-SiteCheck { param([string]$Url) Write-StepHeader 'Production site check' Write-Info $Url try { $response = Invoke-WebRequest -Uri $Url -MaximumRedirection 5 -TimeoutSec 30 if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) { Write-Ok "HTTP $($response.StatusCode) from production" return } throw "Unexpected HTTP $($response.StatusCode) from $Url" } catch { $message = "Post-deploy site check failed: $($_.Exception.Message). Verify URL: $Url" if ($SkipSiteCheck) { Write-WarnLine $message } else { Write-Fail $message throw $message } } } function Get-RemoteSmokeSecret { param( [string]$SshTarget, [string[]]$SshArgList, [string]$DeployPath ) # Pulls DEPLOY_SMOKE_SECRET from the live .env that was just merged by the # deploy. Single source of truth — no need to sync template and live. $remoteCmd = "grep -E '^DEPLOY_SMOKE_SECRET=' '$DeployPath/.env' | tail -n1 | cut -d= -f2-" $output = & ssh @SshArgList $SshTarget $remoteCmd 2>$null if ($LASTEXITCODE -ne 0) { return $null } if ([string]::IsNullOrWhiteSpace($output)) { return $null } return ($output -join '').Trim() } function Invoke-FormSmokeTest { param( [string]$Url, [string]$Secret, [hashtable]$Payload, [string]$Label ) $json = $Payload | ConvertTo-Json -Compress -Depth 6 $headers = @{ 'X-Deploy-Smoke' = $Secret 'Content-Type' = 'application/json' 'User-Agent' = 'goodwalk-deploy-smoke/1.0' } try { $response = Invoke-WebRequest -Uri $Url -Method Post -Headers $headers -Body $json -TimeoutSec 30 -MaximumRedirection 0 } catch { throw "$Label smoke test failed: $($_.Exception.Message)" } if ($response.StatusCode -ne 200) { throw "$Label smoke test returned HTTP $($response.StatusCode) (expected 200)" } $body = $null try { $body = $response.Content | ConvertFrom-Json } catch { } if (-not $body -or -not $body.ok -or -not $body.smoke) { throw "$Label smoke test response did not include ok=true,smoke=true. Body: $($response.Content)" } Write-Ok "$Label OK (request_id=$($body.request_id))" } function Invoke-AllFormSmokeTests { param( [string]$SshTarget, [string[]]$SshArgList, [string]$DeployPath ) Write-StepHeader 'Production form smoke tests' $secret = Get-RemoteSmokeSecret -SshTarget $SshTarget -SshArgList $SshArgList -DeployPath $DeployPath if ([string]::IsNullOrWhiteSpace($secret)) { $msg = "DEPLOY_SMOKE_SECRET not found in $DeployPath/.env on the server. Smoke tests cannot run." if ($SkipSiteCheck) { Write-WarnLine $msg return } Write-Fail $msg throw $msg } # Payloads carry only the fields each Pydantic model requires. Validation, # rate limiting, honeypot, email send and DB writes are all bypassed by the # mail-api smoke shortcut, but Pydantic still parses the body — so this also # catches schema breakage. $smokeEmail = 'deploy-smoke@goodwalk.co.nz' $bookingPayload = @{ fullName = 'Deploy Smoke' email = $smokeEmail phone = '+64-00-000-0000' enquiryType = 'booking' page = '/deploy-smoke' } $onboardingPayload = @{ fullName = 'Deploy Smoke' email = $smokeEmail phone = '+64-00-000-0000' address = '1 Smoke St, Auckland' dogName = 'Smoke' dogBreed = 'Test' vetName = 'Dr Smoke' vetPhone = '+64-00-000-0000' emergencyContactName = 'Smoke Contact' emergencyContactPhone = '+64-00-000-0000' signatureDataUrl = 'data:image/png;base64,iVBORw0KGgo=' page = '/deploy-smoke' } $contractPayload = @{ fullName = 'Deploy Smoke' email = $smokeEmail phone = '+64-00-000-0000' address = '1 Smoke St, Auckland' dogName = 'Smoke' dogBreed = 'Test' serviceType = 'pack-walks' startDate = '2099-01-01' signatureDataUrl = 'data:image/png;base64,iVBORw0KGgo=' page = '/deploy-smoke' } # Each endpoint is exercised on the subdomain that actually hosts it # in production (see nginx/goodwalk.co.nz.svelte.conf.example): # /api/submit → www.goodwalk.co.nz # /api/onboarding-submit → clients.goodwalk.co.nz # /api/contract-submit → clients.goodwalk.co.nz Invoke-FormSmokeTest -Url 'https://www.goodwalk.co.nz/api/submit' -Secret $secret -Payload $bookingPayload -Label 'booking form (www /api/submit)' Invoke-FormSmokeTest -Url 'https://clients.goodwalk.co.nz/api/onboarding-submit' -Secret $secret -Payload $onboardingPayload -Label 'onboarding form (clients /api/onboarding-submit)' Invoke-FormSmokeTest -Url 'https://clients.goodwalk.co.nz/api/contract-submit' -Secret $secret -Payload $contractPayload -Label 'contract form (clients /api/contract-submit)' } Show-DeployBanner Assert-Command ssh Assert-Command scp Assert-Command tar Assert-Command node Assert-NotBlank -Name 'SshUser' -Value $SshUser Assert-NotBlank -Name 'ServerHost' -Value $ServerHost Assert-NotBlank -Name 'LocalProjectPath' -Value $LocalProjectPath Assert-NotBlank -Name 'RemoteDeploymentPath' -Value $RemoteDeploymentPath Assert-NotBlank -Name 'ComposeFileName' -Value $ComposeFileName Assert-NotBlank -Name 'DockerProjectName' -Value $DockerProjectName Assert-NotBlank -Name 'NginxConfigSource' -Value $NginxConfigSource Assert-NotBlank -Name 'NginxConfigTarget' -Value $NginxConfigTarget Assert-NotBlank -Name 'NginxComposeFile' -Value $NginxComposeFile Assert-NotBlank -Name 'NginxProjectName' -Value $NginxProjectName Assert-NotBlank -Name 'MaintenanceHostDir' -Value $MaintenanceHostDir Assert-NotBlank -Name 'MaintenanceFlagPath' -Value $MaintenanceFlagPath if (-not [string]::IsNullOrWhiteSpace($Service)) { $Service = $Service.Trim() } if (-not [string]::IsNullOrWhiteSpace($SshConfigPath) -and -not (Test-Path -LiteralPath $SshConfigPath)) { throw "SSH config file not found: $SshConfigPath" } if (-not [string]::IsNullOrWhiteSpace($SshKeyPath) -and -not (Test-Path -LiteralPath $SshKeyPath)) { throw "SSH key not found: $SshKeyPath" } if (-not (Test-Path -LiteralPath $LocalProjectPath)) { throw "Local project path not found: $LocalProjectPath" } if (-not (Test-Path -LiteralPath (Join-Path $LocalProjectPath $ComposeFileName))) { throw "Compose file not found in local project path: $(Join-Path $LocalProjectPath $ComposeFileName)" } if (-not (Test-Path -LiteralPath $LocalRemoteHelperPath)) { throw "Remote deployment helper not found: $LocalRemoteHelperPath" } $sshTarget = '{0}@{1}' -f $SshUser, $ServerHost $scpArchiveTarget = '{0}:{1}' -f $sshTarget, $RemoteArchivePath $scpHelperTarget = '{0}:{1}' -f $sshTarget, $RemoteHelperPath $sshArgs = Get-SshArgumentList Write-Section 'Configuration' Write-Field 'Local project' $LocalProjectPath Write-Field 'Remote path' $RemoteDeploymentPath Write-Field 'Compose file' $ComposeFileName Write-Field 'Docker project' $DockerProjectName Write-Field 'Shared nginx conf' $NginxConfigTarget Write-Field 'Shared nginx compose' $NginxComposeFile Write-Field 'Maintenance dir' "$MaintenanceHostDir (mounted at /var/www/maintenance:ro)" Write-Field 'Maintenance flag' $MaintenanceFlagPath Write-Field 'SSH target' $sshTarget Write-Field 'SSH config' $SshConfigPath if (-not [string]::IsNullOrWhiteSpace($Service)) { Write-Field 'Target service' $Service } if ($SeedAdminData) { Write-Field 'Admin data' 'seed postgres from JSON on next mail-api boot' } if ($SkipLegacySeed) { Write-Field 'Legacy clients seed' 'SKIPPED (existing mail-api/legacy-clients-seed.json reused)' } else { Write-Field 'Legacy clients seed' 'rebuild from data/legacy-*.json (add-only merge on boot)' } if ([string]::IsNullOrWhiteSpace($SshKeyPath)) { Write-Field 'SSH auth' 'interactive password prompt' } else { Write-Field 'SSH auth' "key file $SshKeyPath" } Write-Section 'Safety notes' Write-Note 'Only the top-level Goodwalk compose project will be updated.' Write-Note 'Legacy WordPress/onboarding compose files are not used.' Write-Note 'Remote .env files are preserved because they are not uploaded.' Write-Note 'No global Docker prune/stop/delete commands are used.' Write-Note 'Shared nginx will be updated and reloaded with the Docker-DNS-based config.' Write-Section 'Subdomains served by this stack' Write-Note 'goodwalk.co.nz / www.goodwalk.co.nz — marketing' Write-Note 'clients.goodwalk.co.nz — client onboarding + contracts' Write-Note 'cp.goodwalk.co.nz — owner admin dashboard' Write-Note 'onboarding/admin remain legacy redirect aliases' if ($SeedAdminData) { Write-Host '' Write-WarnLine 'Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.' } if (-not $Force) { Write-Host '' Write-Host -NoNewline (" " + (Format-Color '?' $script:CYellow) + " ") Write-Host -NoNewline (Format-Color 'Type ' $script:CGrey) Write-Host -NoNewline (Format-Color 'DEPLOY' $script:CYellow) Write-Host -NoNewline (Format-Color " to continue with remote path '" $script:CGrey) Write-Host -NoNewline (Format-Color $RemoteDeploymentPath $script:CCyan) Write-Host -NoNewline (Format-Color "' " $script:CGrey) $confirmation = Read-Host if ($confirmation -ne 'DEPLOY') { Write-Fail 'Deployment cancelled.' throw 'Deployment cancelled.' } } $archivePath = $null $uploadHelperPath = $null $totalWatch = [System.Diagnostics.Stopwatch]::StartNew() $stepWatch = [System.Diagnostics.Stopwatch]::new() try { Write-StepHeader 'Export homepage content for PostgreSQL sync' $stepWatch.Restart() Export-HomepageContent -ProjectPath $LocalProjectPath -OutputPath $GeneratedHomepageContentPath Write-Ok 'Homepage content exported' $stepWatch.Elapsed.TotalSeconds if (-not $SkipLegacySeed) { Write-StepHeader 'Build legacy clients seed for mail-api image' $stepWatch.Restart() $legacySeedPath = Build-LegacySeed -ProjectPath $LocalProjectPath Write-Info "Seed file: $legacySeedPath" Write-Ok 'Legacy clients seed ready' $stepWatch.Elapsed.TotalSeconds } else { Write-Note 'Legacy clients seed step skipped (-SkipLegacySeed).' } Write-StepHeader 'Create deployment archive' $stepWatch.Restart() $archivePath = New-DeployArchive -ProjectPath $LocalProjectPath Write-Info "Archive: $archivePath" Write-Ok 'Archive ready' $stepWatch.Elapsed.TotalSeconds Write-StepHeader 'Upload remote helper' $stepWatch.Restart() $uploadHelperPath = New-UnixScriptCopy -SourcePath $LocalRemoteHelperPath Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($uploadHelperPath, $scpHelperTarget)) Write-Ok 'Helper uploaded' $stepWatch.Elapsed.TotalSeconds Write-StepHeader 'Upload application archive' $stepWatch.Restart() Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($archivePath, $scpArchiveTarget)) Write-Ok 'Archive uploaded' $stepWatch.Elapsed.TotalSeconds Write-StepHeader 'Run remote deployment' $stepWatch.Restart() Invoke-External -FilePath 'ssh' -Arguments ($sshArgs + @( $sshTarget, 'bash', $RemoteHelperPath, '--archive', $RemoteArchivePath, '--deploy-path', $RemoteDeploymentPath, '--compose-file', $ComposeFileName, '--project-name', $DockerProjectName, '--nginx-source', $NginxConfigSource, '--nginx-target', $NginxConfigTarget, '--nginx-compose-file', $NginxComposeFile, '--nginx-project-name', $NginxProjectName, '--maintenance-host-dir', $MaintenanceHostDir, '--maintenance-flag', $MaintenanceFlagPath ) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }) ` + $(if ($SeedAdminData) { @('--seed-admin-data') } else { @() })) Write-Ok 'Remote deployment finished' $stepWatch.Elapsed.TotalSeconds Write-StepHeader 'Clean remote temporary files' $stepWatch.Restart() Invoke-External -FilePath 'ssh' -Arguments ($sshArgs + @( $sshTarget, 'rm', '-f', $RemoteArchivePath, $RemoteHelperPath )) Write-Ok 'Remote temp files cleaned' $stepWatch.Elapsed.TotalSeconds if (-not $SkipSiteCheck) { Invoke-SiteCheck -Url $VerifyUrl Invoke-AllFormSmokeTests -SshTarget $sshTarget -SshArgList $sshArgs -DeployPath $RemoteDeploymentPath } $totalWatch.Stop() $border = '──────────────────────────────────────────────────────' Write-Host '' Write-Host (Format-Color " ╭$border╮" $script:CGreen) Write-Host -NoNewline (Format-Color ' │' $script:CGreen) Write-Host -NoNewline (Format-Color ' ✓ ' $script:COk) Write-Host -NoNewline (Format-Color 'Deployment complete' $script:CYellow) $elapsedText = (' · {0:N1}s total' -f $totalWatch.Elapsed.TotalSeconds) Write-Host -NoNewline (Format-Color $elapsedText $script:CDim) $visibleLen = (' ✓ Deployment complete' + $elapsedText).Length $padLen = 54 - $visibleLen if ($padLen -lt 1) { $padLen = 1 } Write-Host (Format-Color ((' ' * $padLen) + '│') $script:CGreen) Write-Host (Format-Color " ╰$border╯" $script:CGreen) Write-Host '' } finally { if ($archivePath -and (Test-Path -LiteralPath $archivePath)) { Remove-Item -LiteralPath $archivePath -Force } if ($uploadHelperPath -and (Test-Path -LiteralPath $uploadHelperPath)) { Remove-Item -LiteralPath $uploadHelperPath -Force } }