Files
gw-svelte/deploy.ps1
T

337 lines
11 KiB
PowerShell

[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
}
}