587 lines
26 KiB
PowerShell
587 lines
26 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
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
|
|
docker compose up --build. No git required on the server.
|
|
|
|
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-<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'.
|
|
|
|
.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'.
|
|
|
|
.PARAMETER Seed
|
|
Run 'python -m app.seed' inside the backend container after the stack is up.
|
|
|
|
.PARAMETER Logs
|
|
Tail logs for ~60 lines after deploy to verify the stack came up.
|
|
|
|
.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] $AppName = "Clients",
|
|
[string] $AppSlug = "clients",
|
|
[string] $RemotePath,
|
|
[string] $BackendContainer,
|
|
[string] $PortEnvKey,
|
|
[string] $EnvFile = ".env.production",
|
|
[string] $SshKey,
|
|
[string] $Password,
|
|
[string] $ComposeFile = "docker-compose.production.yml",
|
|
[switch] $Seed,
|
|
[switch] $Logs,
|
|
[switch] $SkipBuild,
|
|
[switch] $Force,
|
|
[switch] $NoBanner
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
Set-StrictMode -Version Latest
|
|
|
|
# ── 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) }
|
|
|
|
# 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 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 {
|
|
$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 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 "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 tree (excluding node_modules, caches, secrets)"
|
|
|
|
$TarFile = Join-Path $env:TEMP "$AppStack-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
|
|
|
|
$excludes = @(
|
|
"--exclude=./node_modules",
|
|
"--exclude=./frontend/node_modules",
|
|
"--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",
|
|
"--exclude=./.env.production",
|
|
"--exclude=./.env.alpha",
|
|
"--exclude=./data_entry_app.db",
|
|
"--exclude=./*.db"
|
|
)
|
|
|
|
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-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))"
|
|
|
|
# ── Upload env file ─────────────────────────────────────────────────────────
|
|
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 ($TarSize MB)"
|
|
Invoke-Scp $TarFile "/tmp/$AppStack-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
|
|
Remove-Item $TarFile -Force
|
|
|
|
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" }
|
|
|
|
$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 ($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}}' $BackendContainer 2>/dev/null || echo missing)
|
|
case "`$status" in
|
|
healthy|running) echo "backend is `$status"; exit 0 ;;
|
|
*) printf '.'; sleep 4 ;;
|
|
esac
|
|
done
|
|
echo; echo 'backend did not become healthy in time' >&2; exit 1
|
|
"@
|
|
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"
|
|
|
|
if ($Seed) {
|
|
Write-Step "Seeding reference data"
|
|
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" -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" -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 ("$($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
|
|
}
|