[CmdletBinding()] param( [switch]$Force, [switch]$SkipSiteCheck, [string]$Service ) # --------------------------------------------------------------------------- # 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" } Push-Location $ProjectPath try { Invoke-External -FilePath 'node' -Arguments @( '--experimental-strip-types', $scriptPath, $OutputPath ) } finally { Pop-Location } } 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-Host '' Write-Host "[deploy] Checking production site: $Url" try { $response = Invoke-WebRequest -Uri $Url -MaximumRedirection 5 -TimeoutSec 30 Write-Host "[deploy] Site responded with HTTP $($response.StatusCode)" } catch { Write-Warning "Production site check failed: $($_.Exception.Message)" } } 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-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" if (-not [string]::IsNullOrWhiteSpace($Service)) { Write-Host "[deploy] Target service: $Service" } if ([string]::IsNullOrWhiteSpace($SshKeyPath)) { Write-Host '[deploy] SSH auth: interactive password prompt' } else { Write-Host "[deploy] 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.' if (-not $Force) { $confirmation = Read-Host "Type DEPLOY to continue with the remote path '$RemoteDeploymentPath'" if ($confirmation -ne 'DEPLOY') { throw 'Deployment cancelled.' } } $archivePath = $null $uploadHelperPath = $null try { Write-Host '' Write-Host '[deploy] Exporting current homepage content for PostgreSQL sync' Export-HomepageContent -ProjectPath $LocalProjectPath -OutputPath $GeneratedHomepageContentPath Write-Host '' Write-Host '[deploy] Creating deployment archive' $archivePath = New-DeployArchive -ProjectPath $LocalProjectPath Write-Host "[deploy] Archive ready: $archivePath" Write-Host '' Write-Host '[deploy] Uploading remote helper' $uploadHelperPath = New-UnixScriptCopy -SourcePath $LocalRemoteHelperPath Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($uploadHelperPath, $scpHelperTarget)) Write-Host '' Write-Host '[deploy] Uploading application archive' Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($archivePath, $scpArchiveTarget)) Write-Host '' Write-Host '[deploy] Running remote deployment' 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 { @() })) Write-Host '' Write-Host '[deploy] Cleaning remote temporary files' Invoke-External -FilePath 'ssh' -Arguments ($sshArgs + @( $sshTarget, 'rm', '-f', $RemoteArchivePath, $RemoteHelperPath )) if (-not $SkipSiteCheck) { Invoke-SiteCheck -Url $VerifyUrl } Write-Host '' Write-Host '[deploy] Deployment completed successfully' } 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 } }