Files
data-entry-app/deploy/Deploy.ps1
T

221 lines
6.5 KiB
PowerShell
Raw Normal View History

2026-05-04 22:21:07 +12:00
<#
.SYNOPSIS
Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
.DESCRIPTION
Runs `docker compose` against `docker-compose.production.yml` on the remote
host. The same script handles first-time bootstrap and subsequent updates:
* On bootstrap (-Bootstrap): creates the remote directory, clones the
repo (or updates if already present), uploads the local env file, and
brings the stack up with `docker compose ... up -d --build`.
* On update (default): SSHes to the host, fetches the requested branch,
uploads a refreshed env file (if changed), then runs
`docker compose ... up -d --build` followed by a healthcheck.
The script never executes destructive commands without asking, except for
recreating containers (which preserves the named Postgres volume).
.PARAMETER RemoteHost
Hostname or IP of the Digital Ocean droplet. Required.
.PARAMETER RemoteUser
SSH user on the droplet. Defaults to `root`.
.PARAMETER RemotePath
Absolute path on the droplet where the repo lives. Defaults to
`/srv/lean101-clients`.
.PARAMETER Branch
Git branch to deploy. Defaults to `main`.
.PARAMETER RepoUrl
Git URL used during bootstrap when the remote directory is empty.
Required only with -Bootstrap.
.PARAMETER EnvFile
Local path to the env file that should land on the droplet as
`<RemotePath>/.env.production`. Defaults to `.env.production`.
.PARAMETER SshKey
Optional path to an SSH private key. If omitted, the script relies on
ssh-agent / default keys.
.PARAMETER ComposeFile
Compose file name on the remote host. Defaults to
`docker-compose.production.yml`.
.PARAMETER Bootstrap
Run first-time setup (clone, upload env, build, up).
.PARAMETER SkipBuild
Pass `--no-build` to docker compose (use when only env changed).
.PARAMETER Seed
Run `python -m app.seed` inside the backend container after the stack is up.
.PARAMETER Logs
After deploy, tail logs for ~20 lines so you can verify the stack came up.
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $RemoteHost,
[string] $RemoteUser = "root",
[string] $RemotePath = "/srv/lean101-clients",
[string] $Branch = "main",
[string] $RepoUrl,
[string] $EnvFile = ".env.production",
[string] $SshKey,
[string] $ComposeFile = "docker-compose.production.yml",
[switch] $Bootstrap,
[switch] $SkipBuild,
[switch] $Seed,
[switch] $Logs
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
function Write-Step($message) {
Write-Host "==> $message" -ForegroundColor Cyan
}
function Write-Warn($message) {
Write-Host "!! $message" -ForegroundColor Yellow
}
function Resolve-RepoRoot {
$scriptDir = Split-Path -Parent $MyInvocation.ScriptName
if (-not $scriptDir) { $scriptDir = $PSScriptRoot }
return (Resolve-Path (Join-Path $scriptDir "..")).Path
}
$RepoRoot = Resolve-RepoRoot
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 $EnvFile and fill in production secrets first."
}
$sshTarget = "$RemoteUser@$RemoteHost"
$sshOpts = @("-o", "StrictHostKeyChecking=accept-new")
if ($SshKey) { $sshOpts += @("-i", $SshKey) }
function Invoke-Ssh([string] $remoteCommand) {
& ssh @sshOpts $sshTarget $remoteCommand
if ($LASTEXITCODE -ne 0) {
throw "Remote command failed (exit $LASTEXITCODE): $remoteCommand"
}
}
function Invoke-Scp([string] $localPath, [string] $remoteDest) {
& scp @sshOpts $localPath "$($sshTarget):$remoteDest"
if ($LASTEXITCODE -ne 0) {
throw "scp failed for $localPath -> $remoteDest"
}
}
Write-Step "Verifying SSH connectivity to $sshTarget"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
Write-Step "Verifying Docker is installed on the droplet"
Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version"
if ($Bootstrap) {
if (-not $RepoUrl) {
throw "-RepoUrl is required when using -Bootstrap."
}
Write-Step "Bootstrapping $RemotePath from $RepoUrl ($Branch)"
$bootstrapScript = @"
set -euo pipefail
mkdir -p '$RemotePath'
cd '$RemotePath'
if [ ! -d .git ]; then
git clone --branch '$Branch' '$RepoUrl' .
else
git remote set-url origin '$RepoUrl'
git fetch origin '$Branch'
git checkout '$Branch'
git reset --hard 'origin/$Branch'
fi
"@
Invoke-Ssh $bootstrapScript
} else {
Write-Step "Updating $RemotePath to latest $Branch"
$updateScript = @"
set -euo pipefail
cd '$RemotePath'
git fetch origin '$Branch'
git checkout '$Branch'
git reset --hard 'origin/$Branch'
"@
Invoke-Ssh $updateScript
}
Write-Step "Uploading $EnvFile to $RemotePath/.env.production"
Invoke-Scp $envPath "$RemotePath/.env.production"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
$composeArgs = @(
"--env-file", ".env.production",
"-f", $ComposeFile
) -join " "
$buildFlag = if ($SkipBuild) { "" } else { "--build" }
Write-Step "Pulling base images"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true"
Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans"
Write-Step "Waiting for backend healthcheck"
$healthScript = @"
set -e
cd '$RemotePath'
for attempt 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)
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
docker compose $composeArgs ps backend
exit 1
"@
Invoke-Ssh $healthScript
if ($Seed) {
Write-Step "Seeding reference data"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs exec -T backend python -m app.seed"
}
Write-Step "Final container status"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs ps"
if ($Logs) {
Write-Step "Recent logs (last 60 lines)"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs logs --tail=60"
}
Write-Host "Deployment complete." -ForegroundColor Green
}
finally {
Pop-Location
}