This commit is contained in:
2026-05-31 20:19:44 +12:00
parent 2f2466ecac
commit 84792c0947
59 changed files with 5412 additions and 898 deletions
+443 -40
View File
@@ -1,6 +1,6 @@
<#
.SYNOPSIS
Deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
Lean 101 deployment script — ships an app to a Digital Ocean droplet over SSH.
.DESCRIPTION
Tars the local source tree, uploads it to the droplet, and runs
@@ -8,14 +8,33 @@
The same script handles first-time setup and subsequent updates.
Designed to be swappable across Lean 101 apps. Override -AppName and
-AppSlug (and any defaults derived from them) to deploy a different app.
.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 and log output.
Defaults to 'Clients'.
.PARAMETER AppSlug
Lowercase slug used to derive default remote path, container names, env files,
and archive names. Defaults to 'clients'. Override to retarget the script at
a different Lean 101 app (e.g. -AppSlug 'ops' for the Ops portal).
.PARAMETER RemotePath
Absolute path on the droplet. Defaults to '/srv/lean101-clients'.
Absolute path on the droplet. Defaults to '/srv/lean101-<AppSlug>'.
.PARAMETER BackendContainer
Backend container name to inspect for health. Defaults to 'lean101-<AppSlug>-backend'.
.PARAMETER PortEnvKey
Env var name in the env file that holds the published port.
Defaults to '<APPSLUG_UPPER>_APP_PORT' (e.g. CLIENTS_APP_PORT).
.PARAMETER EnvFile
Local path to the production env file. Defaults to '.env.production'.
@@ -23,6 +42,11 @@
.PARAMETER SshKey
Optional path to an SSH private key.
.PARAMETER Password
Optional SSH password for password-based auth (no key). Requires sshpass on
PATH. The password is handed to ssh/scp via the SSHPASS environment variable
rather than an interactive prompt, because the script redirects ssh's I/O.
.PARAMETER ComposeFile
Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.
@@ -35,54 +59,276 @@
.PARAMETER SkipBuild
Pass --no-build to docker compose (use when only env changed).
.PARAMETER Force
Skip the remote port-availability preflight. Use when you know the port is
held by this same stack and want to redeploy over it regardless.
.PARAMETER NoBanner
Suppress the ASCII banner (useful in CI).
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs
.EXAMPLE
# Password auth instead of an SSH key (requires sshpass on PATH):
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Password 'your-password' -Seed -Logs
.EXAMPLE
# Retarget the script at a different Lean 101 app:
./deploy/Deploy.ps1 -RemoteHost 1.2.3.4 -AppName 'Ops' -AppSlug 'ops'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $RemoteHost,
[string] $RemoteUser = "root",
[string] $RemotePath = "/srv/lean101-clients",
[string] $EnvFile = ".env.production",
[string] $RemoteUser = "root",
[string] $AppName = "Clients",
[string] $AppSlug = "clients",
[string] $RemotePath,
[string] $BackendContainer,
[string] $PortEnvKey,
[string] $EnvFile = ".env.production",
[string] $SshKey,
[string] $ComposeFile = "docker-compose.production.yml",
[string] $Password,
[string] $ComposeFile = "docker-compose.production.yml",
[switch] $Seed,
[switch] $Logs,
[switch] $SkipBuild
[switch] $SkipBuild,
[switch] $Force,
[switch] $NoBanner
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
# ── Helpers ───────────────────────────────────────────────────────────────────
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host "!! $msg" -ForegroundColor Yellow }
# ── App identity (swappable) ──────────────────────────────────────────────────
$AppSlug = $AppSlug.ToLowerInvariant()
$AppStack = "lean101-$AppSlug"
if (-not $RemotePath) { $RemotePath = "/srv/$AppStack" }
if (-not $BackendContainer) { $BackendContainer = "$AppStack-backend" }
if (-not $PortEnvKey) { $PortEnvKey = "$($AppSlug.ToUpperInvariant())_APP_PORT" }
# ── Palette ───────────────────────────────────────────────────────────────────
# ANSI escapes give us truecolor + dim/bold that Write-Host -ForegroundColor cannot.
$Esc = [char]27
$C = @{
Reset = "$Esc[0m"
Dim = "$Esc[2m"
Bold = "$Esc[1m"
Italic = "$Esc[3m"
Magenta = "$Esc[38;5;177m" # soft violet
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 Deployment Script"
$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.Pink) ██╗ ███████╗ █████╗ ███╗ ██╗ ███╗ ██████╗ ███╗$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ██║ ██╔════╝██╔══██╗████╗ ██║ ████║██╔═████╗ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ██║ █████╗ ███████║██╔██╗ ██║ ██╔██║██║██╔██║ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ██║ ██╔══╝ ██╔══██║██║╚██╗██║ ╚═╝██║████╔╝██║ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ███████╗███████╗██║ ██║██║ ╚████║ ███████╗╚██████╔╝ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Magenta)$($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 ""
}
# ── Output helpers ────────────────────────────────────────────────────────────
$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 ───────────────────────────────────────────────────────────────────
$Spinner = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏')
function Invoke-Spinner {
<#
Runs an external process while animating a braille spinner with elapsed time.
Captures stdout/stderr and surfaces them on failure (or via -Quiet:$false).
#>
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
}
}
# ── Helpers ───────────────────────────────────────────────────────────────────
function Get-RepoRoot {
$dir = Split-Path -Parent $PSScriptRoot
if (-not $dir) { $dir = (Get-Location).Path }
return $dir
}
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")
if ($SshKey) { $SshOpts += @("-i", $SshKey) }
function Invoke-Ssh([string] $cmd) {
& ssh @SshOpts $SshTarget $cmd
if ($LASTEXITCODE -ne 0) { throw "Remote command failed (exit $LASTEXITCODE): $cmd" }
# Password auth (no SSH key): the spinner runs ssh/scp with redirected I/O, so
# there is no terminal for an interactive password prompt. Feed the password
# non-interactively via sshpass instead, reading it from the SSHPASS env var.
$SshExe = 'ssh'
$ScpExe = 'scp'
$SshPrefix = @()
$ScpPrefix = @()
if ($Password) {
if (-not (Get-Command sshpass -ErrorAction SilentlyContinue)) {
throw "sshpass is required for -Password but was not found on PATH. Install it (e.g. 'scoop install sshpass') or use an SSH key."
}
$env:SSHPASS = $Password
$SshExe = 'sshpass'; $SshPrefix = @('-e', 'ssh')
$ScpExe = 'sshpass'; $ScpPrefix = @('-e', 'scp')
$SshOpts += @("-o", "PubkeyAuthentication=no", "-o", "PreferredAuthentications=password")
}
function Invoke-Scp([string] $local, [string] $remote) {
& scp @SshOpts $local "${SshTarget}:${remote}"
if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" }
function Write-RemoteScript([string] $path, [string] $content) {
# Remote bash chokes on Windows CRLF line endings (each line gets a trailing
# \r) and on a UTF-8 BOM. Write LF-only, no BOM.
$lf = $content -replace "`r`n", "`n" -replace "`r", "`n"
[System.IO.File]::WriteAllText($path, $lf, (New-Object System.Text.UTF8Encoding($false)))
}
function Invoke-Ssh([string] $cmd, [string] $Label, [switch] $ShowOutput) {
if (-not $Label) { $Label = "ssh $($cmd.Substring(0, [Math]::Min(48, $cmd.Length)))..." }
# Send the command over stdin to `bash -s` rather than as a command-line
# argument. Complex commands (quotes, pipes, $(), newlines) get corrupted
# when passed as an argv token through sshpass's cygwin argument parsing;
# stdin keeps the only argv tokens simple ("bash -s").
$tmp = [System.IO.Path]::GetTempFileName()
try {
Write-RemoteScript $tmp $cmd
Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList (@($SshPrefix) + @($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput:$ShowOutput
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
function Invoke-Scp([string] $local, [string] $remote, [string] $Label) {
if (-not $Label) { $Label = "scp $(Split-Path -Leaf $local) $($Glyph.Arrow) $remote" }
Invoke-Spinner -Label $Label -FilePath $ScpExe -ArgList (@($ScpPrefix) + @($SshOpts) + @($local, "${SshTarget}:${remote}"))
}
function Try-Ssh([string] $cmd) {
$tmp = [System.IO.Path]::GetTempFileName()
try {
Write-RemoteScript $tmp $cmd
Get-Content $tmp -Raw | & $SshExe @SshPrefix @SshOpts $SshTarget "bash -s"
return $LASTEXITCODE
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
function Invoke-SshScript([string] $script, [string] $Label) {
if (-not $Label) { $Label = "ssh (remote script)" }
$tmp = [System.IO.Path]::GetTempFileName()
try {
Write-RemoteScript $tmp $script
Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList (@($SshPrefix) + @($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
# ── Render banner ─────────────────────────────────────────────────────────────
Write-Banner
# ── Resolve paths ─────────────────────────────────────────────────────────────
Push-Location $RepoRoot
try {
@@ -90,15 +336,69 @@ try {
if (-not (Test-Path $EnvPath)) {
throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets."
}
$WorkbookCandidates = @(
(Join-Path $RepoRoot "input_data\\1.xlsx"),
(Join-Path $RepoRoot "Input Cost Spreadsheet(1).xlsx")
)
$WorkbookPath = $WorkbookCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $WorkbookPath) {
throw "Workbook not found. Checked: $($WorkbookCandidates -join ', '). The production seed expects at least one workbook file to exist."
}
$AppPort = Get-EnvValue $EnvPath $PortEnvKey
if (-not $AppPort) { $AppPort = "8081" }
$Origin = Get-EnvValue $EnvPath "ORIGIN"
if (-not $Origin) { $Origin = "https://clients.lean-101.com.au" }
Write-Step "Preflight"
Write-Info "App : $AppName ($AppSlug)"
Write-Info "Remote host : $SshTarget"
Write-Info "Remote path : $RemotePath"
Write-Info "Container : $BackendContainer"
Write-Info "Env file : $EnvPath"
Write-Info "Compose file : $ComposeFile"
Write-Info "Origin : $Origin"
Write-Info "Port ($PortEnvKey) : $AppPort"
# ── Connectivity check ──────────────────────────────────────────────────────
Write-Step "Checking SSH connectivity to $SshTarget"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
Write-Step "Verifying SSH connectivity"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" -Label "ssh handshake" -ShowOutput
# ── Remote port availability ───────────────────────────────────────────────
# A re-deploy of this same stack is expected to replace its containers in
# place, so any container belonging to this stack (name == STACK or STACK-*)
# is treated as "ours". Only a genuinely different service triggers an abort.
if ($Force) {
Write-Step "Skipping remote port check for $AppStack (-Force)"
}
else {
Write-Step "Checking that remote port $AppPort is free for $AppStack"
$portCheckCmd = @'
set -e
PORT='__APP_PORT__'
STACK='__APP_STACK__'
OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true)
if [ -n "$OWNER" ]; then
case "$OWNER" in
"$STACK"|"$STACK"-*) : ;;
*)
echo "Port $PORT is already owned by container: $OWNER" >&2
exit 2 ;;
esac
fi
'@.Replace('__APP_PORT__', $AppPort).Replace('__APP_STACK__', $AppStack)
try {
Invoke-Ssh $portCheckCmd -Label "port $AppPort availability"
}
catch {
throw "Remote port $AppPort is already in use by a different service. Change $PortEnvKey in '$EnvPath', retire the conflicting service, or re-run with -Force to deploy anyway."
}
}
# ── Package source files ────────────────────────────────────────────────────
Write-Step "Packaging source files (excluding node_modules, caches, etc.)"
Write-Step "Packaging source tree (excluding node_modules, caches, secrets)"
$TarFile = Join-Path $env:TEMP "lean101-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
$TarFile = Join-Path $env:TEMP "$AppStack-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
$excludes = @(
"--exclude=./node_modules",
@@ -106,9 +406,14 @@ try {
"--exclude=./frontend/.svelte-kit",
"--exclude=./frontend/build",
"--exclude=./.git",
"--exclude=./.pytest_cache",
"--exclude=./__pycache__",
"--exclude=./backend/__pycache__",
"--exclude=./backend/app/__pycache__",
"--exclude=./backend/.pytest_cache",
"--exclude=./backend/.tmp",
"--exclude=./backend/.venv",
"--exclude=./backend/data_entry_app_backend.egg-info",
"--exclude=./**/__pycache__",
"--exclude=./*.pyc",
"--exclude=./.env",
@@ -118,39 +423,48 @@ try {
"--exclude=./*.db"
)
& tar -czf $TarFile @excludes -C $RepoRoot .
if ($LASTEXITCODE -ne 0) { throw "tar failed" }
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, 1)
Write-Host " Archive: $TarFile ($TarSize MB)"
Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))"
# ── Upload env file ─────────────────────────────────────────────────────────
Write-Step "Uploading env file"
Invoke-Scp $EnvPath "$RemotePath/.env.production"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
Write-Step "Uploading env file to droplet"
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"
# ── Upload and extract source ────────────────────────────────────────────────
Write-Step "Uploading source archive"
Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz"
Write-Step "Uploading source archive ($TarSize MB)"
Invoke-Scp $TarFile "/tmp/$AppStack-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
Remove-Item $TarFile -Force
Write-Step "Extracting on server"
Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/lean101-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-deploy.tar.gz"
Write-Step "Extracting archive on server"
Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/$AppStack-deploy.tar.gz -C '$RemotePath' && rm /tmp/$AppStack-deploy.tar.gz" -Label "untar into $RemotePath"
# ── Docker compose up ───────────────────────────────────────────────────────
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
Write-Step "Bringing stack up (build=$(-not $SkipBuild))"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
$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 remote status and backend logs"
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=120 backend"
throw
}
# ── Health check ────────────────────────────────────────────────────────────
Write-Step "Waiting for backend health check"
Write-Step "Waiting for backend health check ($BackendContainer)"
$healthScript = @"
set -e
cd '$RemotePath'
for i in `$(seq 1 30); do
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing)
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $BackendContainer 2>/dev/null || echo missing)
case "`$status" in
healthy|running) echo "backend is `$status"; exit 0 ;;
*) printf '.'; sleep 4 ;;
@@ -158,25 +472,114 @@ for i in `$(seq 1 30); do
done
echo; echo 'backend did not become healthy in time' >&2; exit 1
"@
Invoke-Ssh $healthScript
Invoke-SshScript $healthScript -Label "backend health (up to ~2 min)"
# ── Seed access ─────────────────────────────────────────────────────────────
Write-Step "Seeding default internal users and permissions"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed_access" -Label "python -m app.seed_access"
# ── Optional seed ───────────────────────────────────────────────────────────
if ($Seed) {
Write-Step "Seeding reference data"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed" -Label "python -m app.seed"
}
# ── Final status ────────────────────────────────────────────────────────────
Write-Step "Stack status"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" -Label "docker compose ps" -ShowOutput
# ── Frontend asset verification ─────────────────────────────────────────────
Write-Step "Verifying published Inter font asset"
$fontCheckBody = @'
set -e
ORIGIN="__ORIGIN__"
fetch() {
URL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$URL"
return
fi
echo "Neither curl nor wget is available on the remote host." >&2
exit 1
}
fetch_status() {
URL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS -o /dev/null -w "%{http_code}" "$URL"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -S --spider "$URL" 2>&1 | awk '/^ HTTP\// { code=$2 } END { if (code) print code; else print "000" }'
return
fi
echo "000"
}
INDEX_HTML=$(fetch "$ORIGIN")
CSS_PATHS=$(printf '%s' "$INDEX_HTML" | grep -oE '/_app/immutable/assets/[^"]+\.css' | sort -u || true)
if [ -z "$CSS_PATHS" ]; then
echo "Could not find a built CSS asset on $ORIGIN" >&2
exit 1
fi
FONT_URL=""
for CSS_PATH in $CSS_PATHS; do
CSS_URL="${ORIGIN%/}${CSS_PATH}"
CSS_CONTENT=$(fetch "$CSS_URL")
FONT_PATH=$(printf '%s' "$CSS_CONTENT" | grep -oE 'inter-latin-400-normal\.[^")]+\.woff2' | head -n 1 || true)
if [ -n "$FONT_PATH" ]; then
FONT_URL="${CSS_URL%/*}/${FONT_PATH}"
break
fi
done
if [ -z "$FONT_PATH" ]; then
echo "Could not find the Inter Latin 400 woff2 asset in any built CSS asset from $ORIGIN" >&2
exit 1
fi
FONT_STATUS=$(fetch_status "$FONT_URL")
if [ "$FONT_STATUS" != "200" ]; then
echo "Inter font check failed: $FONT_URL returned HTTP $FONT_STATUS" >&2
exit 1
fi
echo "Verified Inter font asset: $FONT_URL"
'@.Replace('__ORIGIN__', $Origin)
Invoke-SshScript $fontCheckBody -Label "Inter font asset check"
if ($Logs) {
Write-Step "Recent logs (last 60 lines)"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" -Label "docker compose logs --tail=60" -ShowOutput
}
Write-Step "Published access"
Write-Info "Container port : $($C.Bold)$AppPort$($C.Reset)"
Write-Info "Origin : $($C.Bold)$($C.Blue)$Origin$($C.Reset)"
if ($AppPort -ne "80" -and $AppPort -ne "443") {
Write-Warn "This stack is published on port $AppPort. The public domain may still point at another service until you swap the reverse proxy or port mapping."
}
# ── Done ────────────────────────────────────────────────────────────────────
$line = '─' * 62
Write-Host ""
Write-Host "Deployment complete -> https://clients.lean-101.com.au" -ForegroundColor Green
Write-Host ("$($C.Green)$line$($C.Reset)")
$done = "$($Glyph.Spark) $AppName deployed $($Glyph.Arrow) $Origin"
$pad = [Math]::Max(0, 60 - ($done.Length - 2)) # subtract 2 for two-byte glyphs
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
+4 -1
View File
@@ -27,7 +27,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
location /_app/immutable/ {
expires 1y;
@@ -88,6 +88,9 @@ server {
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
expires -1;
proxy_pass http://lean101_clients_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;