tweaks
This commit is contained in:
+443
-40
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user