This commit is contained in:
2026-05-26 08:30:08 +12:00
parent 005aab8139
commit 135a5a3b83
75 changed files with 22417 additions and 4288 deletions
+330 -49
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[switch]$Force,
[switch]$SkipSiteCheck,
@@ -8,9 +8,94 @@ param(
# 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
[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.
@@ -154,6 +239,32 @@ function Export-HomepageContent {
}
}
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
@@ -175,13 +286,13 @@ function New-UnixScriptCopy {
function Invoke-SiteCheck {
param([string]$Url)
Write-Host ''
Write-Host "[deploy] Checking production site: $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-Host "[deploy] Site responded with HTTP $($response.StatusCode)"
Write-Ok "HTTP $($response.StatusCode) from production"
return
}
throw "Unexpected HTTP $($response.StatusCode) from $Url"
@@ -189,13 +300,136 @@ function Invoke-SiteCheck {
catch {
$message = "Post-deploy site check failed: $($_.Exception.Message). Verify URL: $Url"
if ($SkipSiteCheck) {
Write-Warning $message
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
@@ -243,75 +477,106 @@ $scpArchiveTarget = '{0}:{1}' -f $sshTarget, $RemoteArchivePath
$scpHelperTarget = '{0}:{1}' -f $sshTarget, $RemoteHelperPath
$sshArgs = Get-SshArgumentList
Write-Host '[deploy] Main Goodwalk website deployment'
Write-Host "[deploy] Local project path: $LocalProjectPath"
Write-Host "[deploy] Remote deployment path: $RemoteDeploymentPath"
Write-Host "[deploy] Remote compose file: $ComposeFileName"
Write-Host "[deploy] Docker project name: $DockerProjectName"
Write-Host "[deploy] Shared nginx config: $NginxConfigTarget"
Write-Host "[deploy] Shared nginx compose file: $NginxComposeFile"
Write-Host "[deploy] Maintenance host dir: $MaintenanceHostDir (must be bind-mounted at /var/www/maintenance:ro)"
Write-Host "[deploy] Maintenance flag path: $MaintenanceFlagPath"
Write-Host "[deploy] SSH target: $sshTarget"
Write-Host "[deploy] SSH config: $SshConfigPath"
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-Host "[deploy] Target service: $Service"
Write-Field 'Target service' $Service
}
if ($SeedAdminData) {
Write-Host '[deploy] Admin data: seeding postgres from JSON on next mail-api boot'
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-Host '[deploy] SSH auth: interactive password prompt'
Write-Field 'SSH auth' 'interactive password prompt'
} else {
Write-Host "[deploy] SSH auth: key file $SshKeyPath"
Write-Field 'SSH auth' "key file $SshKeyPath"
}
Write-Host ''
Write-Host '[deploy] Safety notes:'
Write-Host ' - Only the top-level Goodwalk compose project will be updated.'
Write-Host ' - Legacy WordPress/onboarding compose files are not used.'
Write-Host ' - Remote .env files are preserved because they are not uploaded.'
Write-Host ' - No global Docker prune/stop/delete commands are used.'
Write-Host ' - Shared nginx will be updated and reloaded with the Docker-DNS-based config.'
Write-Host ' - Subdomains served by this stack:'
Write-Host ' goodwalk.co.nz / www.goodwalk.co.nz (marketing)'
Write-Host ' clients.goodwalk.co.nz (client onboarding + contracts)'
Write-Host ' cp.goodwalk.co.nz (owner admin dashboard)'
Write-Host ' onboarding/admin remain legacy redirect aliases'
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 ' - Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.'
Write-Host ''
Write-WarnLine 'Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.'
}
if (-not $Force) {
$confirmation = Read-Host "Type DEPLOY to continue with the remote path '$RemoteDeploymentPath'"
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-Host ''
Write-Host '[deploy] Exporting current homepage content for PostgreSQL sync'
Write-StepHeader 'Export homepage content for PostgreSQL sync'
$stepWatch.Restart()
Export-HomepageContent -ProjectPath $LocalProjectPath -OutputPath $GeneratedHomepageContentPath
Write-Ok 'Homepage content exported' $stepWatch.Elapsed.TotalSeconds
Write-Host ''
Write-Host '[deploy] Creating deployment archive'
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-Host "[deploy] Archive ready: $archivePath"
Write-Info "Archive: $archivePath"
Write-Ok 'Archive ready' $stepWatch.Elapsed.TotalSeconds
Write-Host ''
Write-Host '[deploy] Uploading remote helper'
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-Host ''
Write-Host '[deploy] Uploading application archive'
Write-StepHeader 'Upload application archive'
$stepWatch.Restart()
Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($archivePath, $scpArchiveTarget))
Write-Ok 'Archive uploaded' $stepWatch.Elapsed.TotalSeconds
Write-Host ''
Write-Host '[deploy] Running remote deployment'
Write-StepHeader 'Run remote deployment'
$stepWatch.Restart()
Invoke-External -FilePath 'ssh' -Arguments ($sshArgs + @(
$sshTarget,
'bash',
@@ -338,9 +603,10 @@ try {
$MaintenanceFlagPath
) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }) `
+ $(if ($SeedAdminData) { @('--seed-admin-data') } else { @() }))
Write-Ok 'Remote deployment finished' $stepWatch.Elapsed.TotalSeconds
Write-Host ''
Write-Host '[deploy] Cleaning remote temporary files'
Write-StepHeader 'Clean remote temporary files'
$stepWatch.Restart()
Invoke-External -FilePath 'ssh' -Arguments ($sshArgs + @(
$sshTarget,
'rm',
@@ -348,13 +614,28 @@ try {
$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 ''
Write-Host '[deploy] Deployment completed successfully'
}
finally {
if ($archivePath -and (Test-Path -LiteralPath $archivePath)) {