[CmdletBinding()] param( [switch]$Force, [switch]$SkipSiteCheck ) # --------------------------------------------------------------------------- # 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' # 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' 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 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-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 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] SSH target: $sshTarget" Write-Host "[deploy] SSH config: $SshConfigPath" 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.' if (-not $Force) { $confirmation = Read-Host "Type DEPLOY to continue with the remote path '$RemoteDeploymentPath'" if ($confirmation -ne 'DEPLOY') { throw 'Deployment cancelled.' } } $archivePath = $null try { 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' Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($LocalRemoteHelperPath, $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 )) 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 } }