2026-05-02 12:39:55 +12:00
|
|
|
[CmdletBinding()]
|
|
|
|
|
param(
|
|
|
|
|
[switch]$Force,
|
2026-05-02 19:44:45 +12:00
|
|
|
[switch]$SkipSiteCheck,
|
|
|
|
|
[string]$Service
|
2026-05-02 12:39:55 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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'
|
2026-05-03 11:16:53 +12:00
|
|
|
$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'
|
2026-05-05 20:54:56 +12:00
|
|
|
# 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'
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
# 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'
|
2026-05-02 19:44:45 +12:00
|
|
|
$GeneratedHomepageContentPath = Join-Path $LocalProjectPath 'deploy-data\homepage-content.json'
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 19:44:45 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:02:24 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 12:39:55 +12:00
|
|
|
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
|
2026-05-02 19:44:45 +12:00
|
|
|
Assert-Command node
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
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
|
2026-05-03 11:16:53 +12:00
|
|
|
Assert-NotBlank -Name 'NginxConfigSource' -Value $NginxConfigSource
|
|
|
|
|
Assert-NotBlank -Name 'NginxConfigTarget' -Value $NginxConfigTarget
|
|
|
|
|
Assert-NotBlank -Name 'NginxComposeFile' -Value $NginxComposeFile
|
|
|
|
|
Assert-NotBlank -Name 'NginxProjectName' -Value $NginxProjectName
|
2026-05-05 20:54:56 +12:00
|
|
|
Assert-NotBlank -Name 'MaintenanceHostDir' -Value $MaintenanceHostDir
|
|
|
|
|
Assert-NotBlank -Name 'MaintenanceFlagPath' -Value $MaintenanceFlagPath
|
2026-05-02 12:39:55 +12:00
|
|
|
|
2026-05-02 19:44:45 +12:00
|
|
|
if (-not [string]::IsNullOrWhiteSpace($Service)) {
|
|
|
|
|
$Service = $Service.Trim()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 12:39:55 +12:00
|
|
|
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"
|
2026-05-03 11:16:53 +12:00
|
|
|
Write-Host "[deploy] Shared nginx config: $NginxConfigTarget"
|
|
|
|
|
Write-Host "[deploy] Shared nginx compose file: $NginxComposeFile"
|
2026-05-05 20:54:56 +12:00
|
|
|
Write-Host "[deploy] Maintenance host dir: $MaintenanceHostDir (must be bind-mounted at /var/www/maintenance:ro)"
|
|
|
|
|
Write-Host "[deploy] Maintenance flag path: $MaintenanceFlagPath"
|
2026-05-02 12:39:55 +12:00
|
|
|
Write-Host "[deploy] SSH target: $sshTarget"
|
|
|
|
|
Write-Host "[deploy] SSH config: $SshConfigPath"
|
2026-05-02 19:44:45 +12:00
|
|
|
if (-not [string]::IsNullOrWhiteSpace($Service)) {
|
|
|
|
|
Write-Host "[deploy] Target service: $Service"
|
|
|
|
|
}
|
2026-05-02 12:39:55 +12:00
|
|
|
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.'
|
2026-05-03 11:16:53 +12:00
|
|
|
Write-Host ' - Shared nginx will be updated and reloaded with the Docker-DNS-based config.'
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
if (-not $Force) {
|
|
|
|
|
$confirmation = Read-Host "Type DEPLOY to continue with the remote path '$RemoteDeploymentPath'"
|
|
|
|
|
if ($confirmation -ne 'DEPLOY') {
|
|
|
|
|
throw 'Deployment cancelled.'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$archivePath = $null
|
2026-05-11 21:02:24 +12:00
|
|
|
$uploadHelperPath = $null
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
try {
|
2026-05-02 19:44:45 +12:00
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Exporting current homepage content for PostgreSQL sync'
|
|
|
|
|
Export-HomepageContent -ProjectPath $LocalProjectPath -OutputPath $GeneratedHomepageContentPath
|
|
|
|
|
|
2026-05-02 12:39:55 +12:00
|
|
|
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'
|
2026-05-11 21:02:24 +12:00
|
|
|
$uploadHelperPath = New-UnixScriptCopy -SourcePath $LocalRemoteHelperPath
|
|
|
|
|
Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($uploadHelperPath, $scpHelperTarget))
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
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',
|
2026-05-03 11:16:53 +12:00
|
|
|
$DockerProjectName,
|
|
|
|
|
'--nginx-source',
|
|
|
|
|
$NginxConfigSource,
|
|
|
|
|
'--nginx-target',
|
|
|
|
|
$NginxConfigTarget,
|
|
|
|
|
'--nginx-compose-file',
|
|
|
|
|
$NginxComposeFile,
|
|
|
|
|
'--nginx-project-name',
|
2026-05-05 20:54:56 +12:00
|
|
|
$NginxProjectName,
|
|
|
|
|
'--maintenance-host-dir',
|
|
|
|
|
$MaintenanceHostDir,
|
|
|
|
|
'--maintenance-flag',
|
|
|
|
|
$MaintenanceFlagPath
|
2026-05-02 19:44:45 +12:00
|
|
|
) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }))
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-05-11 21:02:24 +12:00
|
|
|
if ($uploadHelperPath -and (Test-Path -LiteralPath $uploadHelperPath)) {
|
|
|
|
|
Remove-Item -LiteralPath $uploadHelperPath -Force
|
|
|
|
|
}
|
2026-05-02 12:39:55 +12:00
|
|
|
}
|