Files
2026-05-17 22:45:45 +12:00

419 lines
17 KiB
PowerShell

<#
.SYNOPSIS
Lean 101 Website deployment script — ships the Flask site to the Digital Ocean droplet over SSH.
.DESCRIPTION
Tars the local source tree, uploads it to the droplet, and runs
docker compose up --build. No git required on the server.
Coexists with the clients app on the same droplet — different container name,
different host port, different remote path. Host nginx routes by hostname.
.PARAMETER RemoteHost
Hostname or IP of the Digital Ocean droplet. Required.
.PARAMETER RemoteUser
SSH user. Defaults to 'root'.
.PARAMETER AppName
Human-readable app name shown in the banner. Defaults to 'Website'.
.PARAMETER AppSlug
Lowercase slug used in container/log labels. Defaults to 'website'.
.PARAMETER RemotePath
Absolute path on the droplet. Defaults to '/srv/lean-101-website-flask'
(deliberately distinct from the old /srv/lean101-website SvelteKit dir).
.PARAMETER ContainerName
Backend container name to inspect. Defaults to 'lean101-website-flask'
(must match container_name in docker-compose.yml).
.PARAMETER PortEnvKey
Env var name in the env file that holds the published port.
Defaults to 'WEBSITE_APP_PORT'.
.PARAMETER EnvFile
Local path to the production env file. Defaults to '.env.production'.
.PARAMETER SshKey
Optional path to an SSH private key.
.PARAMETER ComposeFile
Compose file name on the remote host. Defaults to 'docker-compose.yml'.
.PARAMETER Logs
Tail logs for ~60 lines after deploy.
.PARAMETER SkipBuild
Pass --no-build to docker compose (use when only env changed).
.PARAMETER NoBanner
Suppress the ASCII banner.
.EXAMPLE
./Deploy.ps1 -RemoteHost 209.38.24.231
.EXAMPLE
./Deploy.ps1 -RemoteHost 209.38.24.231 -Logs
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $RemoteHost,
[string] $RemoteUser = "root",
[string] $AppName = "Website",
[string] $AppSlug = "website",
[string] $RemotePath = "/srv/lean-101-website-flask",
[string] $ContainerName = "lean101-website-flask",
[string] $PortEnvKey = "WEBSITE_APP_PORT",
[string] $EnvFile = ".env.production",
[string] $SshKey,
[string] $ComposeFile = "docker-compose.yml",
[switch] $Logs,
[switch] $SkipBuild,
[switch] $NoBanner
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
# Palette
$Esc = [char]27
$C = @{
Reset = "$Esc[0m"; Dim = "$Esc[2m"; Bold = "$Esc[1m"
Magenta = "$Esc[38;5;177m"; Pink = "$Esc[38;5;213m"
Cyan = "$Esc[38;5;87m"; Teal = "$Esc[38;5;79m"
Green = "$Esc[38;5;120m"; Yellow = "$Esc[38;5;221m"
Red = "$Esc[38;5;203m"; Grey = "$Esc[38;5;244m"
Blue = "$Esc[38;5;117m"
}
$Glyph = @{ Step='▸'; OK='✓'; Warn='!'; Info='·'; Fail='✗'; Arrow='→'; Spark='✦' }
function Write-Banner {
if ($NoBanner) { return }
$line = '─' * 62
$title = "Lean 101 Website Deployment"
$sub = "App: $AppName Slug: $AppSlug Target: $RemoteUser@$RemoteHost"
Write-Host ""
Write-Host ("$($C.Magenta)$line$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Spark) $title$($C.Reset)" + (' ' * (60 - $title.Length - 3)) + "$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Dim)$($C.Grey)$sub$($C.Reset)" + (' ' * [Math]::Max(0, 60 - $sub.Length - 2)) + "$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$line$($C.Reset)")
Write-Host ""
}
$script:StepIndex = 0
function Write-Step([string]$msg) {
$script:StepIndex++
$num = "{0:D2}" -f $script:StepIndex
Write-Host ("$($C.Dim)$($C.Grey)[$num]$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Step)$($C.Reset) $($C.Bold)$msg$($C.Reset)")
}
function Write-Ok([string]$msg) { Write-Host (" $($C.Green)$($Glyph.OK)$($C.Reset) $($C.Green)$msg$($C.Reset)") }
function Write-Warn([string]$msg) { Write-Host (" $($C.Yellow)$($Glyph.Warn)$($C.Reset) $($C.Yellow)$msg$($C.Reset)") }
function Write-Info([string]$msg) { Write-Host (" $($C.Dim)$($C.Grey)$($Glyph.Info) $msg$($C.Reset)") }
function Write-Fail([string]$msg) { Write-Host (" $($C.Red)$($Glyph.Fail)$($C.Reset) $($C.Red)$msg$($C.Reset)") }
$Spinner = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏')
function Invoke-Spinner {
param(
[Parameter(Mandatory = $true)] [string] $Label,
[Parameter(Mandatory = $true)] [string] $FilePath,
[string[]] $ArgList = @(),
[string] $StdinFile,
[switch] $ShowOutput
)
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
$startParams = @{
FilePath = $FilePath; NoNewWindow = $true; PassThru = $true
RedirectStandardOutput = $outFile; RedirectStandardError = $errFile
}
if ($ArgList.Count -gt 0) { $startParams.ArgumentList = $ArgList }
if ($StdinFile) { $startParams.RedirectStandardInput = $StdinFile }
$proc = Start-Process @startParams
$start = Get-Date
$i = 0
try {
while (-not $proc.HasExited) {
$elapsed = ((Get-Date) - $start).TotalSeconds
$frame = $Spinner[$i % $Spinner.Count]
Write-Host ("`r $($C.Cyan)$frame$($C.Reset) $($C.Dim)$($C.Grey)$Label $($C.Reset)$($C.Teal)$('{0,5:0.0}s' -f $elapsed)$($C.Reset) ") -NoNewline
Start-Sleep -Milliseconds 90
$i++
}
$proc.WaitForExit()
$elapsed = ((Get-Date) - $start).TotalSeconds
$stdout = if (Test-Path $outFile) { Get-Content $outFile -Raw } else { '' }
$stderr = if (Test-Path $errFile) { Get-Content $errFile -Raw } else { '' }
if ($proc.ExitCode -eq 0) {
Write-Host ("`r $($C.Green)$($Glyph.OK)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed)$($C.Reset)" + (' ' * 12))
if ($ShowOutput -and $stdout) {
foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Info $ln } }
}
return $stdout
} else {
Write-Host ("`r $($C.Red)$($Glyph.Fail)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed) (exit $($proc.ExitCode))$($C.Reset)" + (' ' * 8))
if ($stdout) { foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Host " $($C.Dim)$($C.Grey)$ln$($C.Reset)" } } }
if ($stderr) { foreach ($ln in ($stderr -split "`r?`n")) { if ($ln) { Write-Host " $($C.Red)$ln$($C.Reset)" } } }
throw "$Label failed (exit $($proc.ExitCode))"
}
} finally {
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
Remove-Item $errFile -Force -ErrorAction SilentlyContinue
}
}
function Get-RepoRoot {
if ($PSScriptRoot) { return $PSScriptRoot }
return (Get-Location).Path
}
function Get-EnvValue([string] $path, [string] $key) {
foreach ($line in Get-Content $path) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith("#")) { continue }
if ($trimmed -notmatch "=") { continue }
$parts = $trimmed -split "=", 2
if ($parts[0].Trim() -eq $key) { return $parts[1].Trim() }
}
return $null
}
$RepoRoot = Get-RepoRoot
$SshTarget = "$RemoteUser@$RemoteHost"
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no", "-i", $SshKey)
# Resolve env file path early so we can read SSH_PASSWORD before the main flow.
$EnvPathEarly = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
function Resolve-PuttyTool([string] $name) {
$cmd = Get-Command $name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
foreach ($candidate in @(
"$env:ProgramFiles\PuTTY\$name.exe",
"${env:ProgramFiles(x86)}\PuTTY\$name.exe",
"$env:LOCALAPPDATA\Programs\PuTTY\$name.exe"
)) {
if ($candidate -and (Test-Path $candidate)) { return $candidate }
}
return $null
}
$SshPassword = $null
$PlinkPath = $null
$PscpPath = $null
if (-not $SshKey) {
if (-not (Test-Path $EnvPathEarly)) {
throw "Env file not found at '$EnvPathEarly'. Create it and add SSH_PASSWORD=<droplet root password> (or pass -SshKey to use key auth)."
}
$SshPassword = Get-EnvValue $EnvPathEarly 'SSH_PASSWORD'
if (-not $SshPassword) {
throw "SSH_PASSWORD is not set in '$EnvPathEarly'. Add a line `SSH_PASSWORD=<your droplet password>` (or pass -SshKey to use key auth instead)."
}
$PlinkPath = Resolve-PuttyTool 'plink'
$PscpPath = Resolve-PuttyTool 'pscp'
if (-not $PlinkPath -or -not $PscpPath) {
throw "plink/pscp (PuTTY) are required for password auth on Windows but were not found. Install PuTTY: ``winget install --id PuTTY.PuTTY -e`` (or ``choco install putty -y``), then reopen PowerShell."
}
# Pre-cache the SSH host key so subsequent -batch calls don't fail on first connect.
# Use cmd's pipe — most reliable way to feed `y` to plink's host-key prompt on Windows.
$cacheOut = & cmd /c "echo y | `"$PlinkPath`" -ssh -pw `"$SshPassword`" `"$SshTarget`" exit 2>&1"
# Ignore exit code: the goal is just to write the host key into the PuTTY registry cache.
$null = $cacheOut
}
function Invoke-Ssh([string] $cmd, [string] $Label, [switch] $ShowOutput) {
if (-not $Label) { $Label = "ssh $($cmd.Substring(0, [Math]::Min(48, $cmd.Length)))..." }
if ($SshPassword) {
$argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, $cmd)
Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -ShowOutput:$ShowOutput
} else {
Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, $cmd)) -ShowOutput:$ShowOutput
}
}
function Invoke-Scp([string] $local, [string] $remote, [string] $Label) {
if (-not $Label) { $Label = "scp $(Split-Path -Leaf $local) $($Glyph.Arrow) $remote" }
if ($SshPassword) {
$argList = @('-scp', '-pw', $SshPassword, '-batch', $local, "${SshTarget}:${remote}")
Invoke-Spinner -Label $Label -FilePath $PscpPath -ArgList $argList
} else {
Invoke-Spinner -Label $Label -FilePath 'scp' -ArgList (@($SshOpts) + @($local, "${SshTarget}:${remote}"))
}
}
function Try-Ssh([string] $cmd) {
if ($SshPassword) {
& $PlinkPath -ssh -pw $SshPassword -batch $SshTarget $cmd
} else {
& ssh @SshOpts $SshTarget $cmd
}
return $LASTEXITCODE
}
function Invoke-SshScript([string] $script, [string] $Label) {
if (-not $Label) { $Label = "ssh (remote script)" }
$tmp = [System.IO.Path]::GetTempFileName()
try {
# Use LF line endings so bash on the remote doesn't choke on CR.
[System.IO.File]::WriteAllText($tmp, ($script -replace "`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
if ($SshPassword) {
$argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, 'bash -s')
Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -StdinFile $tmp -ShowOutput
} else {
Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput
}
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
Write-Banner
Push-Location $RepoRoot
try {
$EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
if (-not (Test-Path $EnvPath)) {
throw "Env file not found at '$EnvPath'. Copy .env.production.example to .env.production and fill in RESEND_API_KEY."
}
$AppPort = Get-EnvValue $EnvPath $PortEnvKey
if (-not $AppPort) { $AppPort = "8083" }
Write-Step "Preflight"
Write-Info "App : $AppName ($AppSlug)"
Write-Info "Remote host : $SshTarget"
Write-Info "Remote path : $RemotePath"
Write-Info "Container : $ContainerName"
Write-Info "Env file : $EnvPath"
Write-Info "Compose file : $ComposeFile"
Write-Info "Port ($PortEnvKey) : $AppPort"
Write-Step "Verifying SSH connectivity"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" -Label "ssh handshake" -ShowOutput
Write-Step "Checking that remote port $AppPort is free for $ContainerName"
$portCheckCmd = @'
set -e
PORT='__APP_PORT__'
SELF='__CONTAINER__'
OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true)
if [ -n "$OWNER" ] && [ "$OWNER" != "$SELF" ]; then
echo "Port $PORT is already owned by container: $OWNER" >&2
exit 2
fi
'@.Replace('__APP_PORT__', $AppPort).Replace('__CONTAINER__', $ContainerName)
try {
Invoke-SshScript $portCheckCmd -Label "port $AppPort availability"
} catch {
throw "Remote port $AppPort is already in use by another container. Change $PortEnvKey in '$EnvPath' or retire the conflicting service first."
}
Write-Step "Packaging source tree (excluding caches, secrets, logs)"
$TarFile = Join-Path $env:TEMP "lean101-website-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
$excludes = @(
"--exclude=./.git",
"--exclude=./.pytest_cache",
"--exclude=./__pycache__",
"--exclude=./**/__pycache__",
"--exclude=./*.pyc",
"--exclude=./.venv",
"--exclude=./venv",
"--exclude=./.env",
"--exclude=./.env.production",
"--exclude=./.env.*.local",
"--exclude=./*.log",
"--exclude=./*.err"
)
Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.'))
$TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 2)
Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))"
Write-Step "Ensuring remote path exists"
Invoke-Ssh "mkdir -p '$RemotePath'" -Label "mkdir -p $RemotePath"
Write-Step "Uploading env file"
Invoke-Scp $EnvPath "$RemotePath/.env.production" -Label "scp .env.production $($Glyph.Arrow) $RemotePath/"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" -Label "chmod 600 .env.production"
Write-Step "Uploading source archive ($TarSize MB)"
Invoke-Scp $TarFile "/tmp/lean101-website-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
Remove-Item $TarFile -Force
Write-Step "Extracting archive on server"
Invoke-Ssh "tar -xzf /tmp/lean101-website-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-website-deploy.tar.gz" -Label "untar into $RemotePath"
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
$buildMsg = if ($SkipBuild) { "without rebuild" } else { "with --build" }
Write-Step "Bringing the $AppName stack up $buildMsg"
$composeUpCmd = "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
try {
Invoke-Ssh $composeUpCmd -Label "docker compose up $BuildFlag"
} catch {
Write-Warn "docker compose up failed; collecting status and logs"
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" | Out-Null
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=120" | Out-Null
throw
}
Write-Step "Waiting for container to be running ($ContainerName)"
$runCheck = @"
set -e
for i in `$(seq 1 30); do
status=`$(docker inspect --format='{{.State.Status}}' $ContainerName 2>/dev/null || echo missing)
case "`$status" in
running) echo "container is running"; exit 0 ;;
*) printf '.'; sleep 2 ;;
esac
done
echo; echo "container did not reach running state" >&2; exit 1
"@
Invoke-SshScript $runCheck -Label "container status (up to ~60s)"
Write-Step "HTTP readiness check on 127.0.0.1:$AppPort"
$curlCmd = "for i in `$(seq 1 20); do code=`$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:$AppPort/ 2>/dev/null || echo 000); if [ `"`$code`" = `"200`" ]; then echo `"HTTP `$code`"; exit 0; fi; sleep 2; done; echo `"never got 200`" >&2; exit 1"
Invoke-Ssh $curlCmd -Label "curl http://127.0.0.1:$AppPort/" -ShowOutput
Write-Step "Stack status"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" -Label "docker compose ps" -ShowOutput
if ($Logs) {
Write-Step "Recent logs (last 60 lines)"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" -Label "docker compose logs --tail=60" -ShowOutput
}
Write-Step "Next steps for cutover"
Write-Info "New container is healthy on $($C.Bold)127.0.0.1:$AppPort$($C.Reset)"
Write-Info "Public domain still hits the OLD container until you swap the nginx vhost:"
Write-Info " ssh $SshTarget"
Write-Info " sed -i.bak 's|proxy_pass http://127.0.0.1:8091;|proxy_pass http://127.0.0.1:$AppPort;|' /etc/nginx/sites-enabled/lean-101.com.au"
Write-Info " nginx -t && systemctl reload nginx"
Write-Info "Then verify: curl -I https://www.lean-101.com.au/"
Write-Info "Once happy: cd /srv/lean101-website && docker compose down"
$line = '─' * 62
Write-Host ""
Write-Host ("$($C.Green)$line$($C.Reset)")
$done = "$($Glyph.Spark) $AppName deployed to ${SshTarget}:$AppPort"
$pad = [Math]::Max(0, 60 - ($done.Length - 2))
Write-Host ("$($C.Green)$($C.Reset) $($C.Bold)$($C.Green)$done$($C.Reset)" + (' ' * $pad) + "$($C.Green)$($C.Reset)")
Write-Host ("$($C.Green)$line$($C.Reset)")
Write-Host ""
}
catch {
Write-Host ""
Write-Fail "Deployment aborted: $($_.Exception.Message)"
Write-Host ""
throw
}
finally {
Pop-Location
}