221 lines
6.5 KiB
PowerShell
221 lines
6.5 KiB
PowerShell
|
|
<#
|
||
|
|
.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
|
||
|
|
}
|