648 lines
22 KiB
PowerShell
648 lines
22 KiB
PowerShell
[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
|
|
}
|
|
}
|