mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 17:21:34 -04:00
No verified server image has been published yet, so the shop server runs development builds. Building on the host meant cloning there and hand-editing compose; the script builds with the local Docker engine, ships the image, backs up the data volume and waits for the health check, printing what a rollback across a schema upgrade needs. docs/nest-storage.md describes it beside the Compose deployment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
7.8 KiB
PowerShell
194 lines
7.8 KiB
PowerShell
<#
|
|
Build the OpenNest.Server image from this checkout with the local Docker engine
|
|
and redeploy it to a remote Docker Compose stack over SSH.
|
|
|
|
Steps: docker build -> docker save -> scp -> docker load on the host -> stop the
|
|
running container -> back up its data volume -> point the stack's compose.yml at
|
|
the new tag -> docker compose up -d -> wait for the container's health check.
|
|
|
|
The image is a development build (VERSION stays 0.0.0-dev); its tag is
|
|
yyyyMMdd-<short sha>, with "-dirty" when server sources have uncommitted
|
|
changes, and SOURCE_REVISION records the full commit. A new server can migrate
|
|
the database schema, and older servers refuse a newer schema, so roll back by
|
|
restoring the volume backup together with the previous tag (both are printed).
|
|
|
|
The host and stack paths are parameters so no private address lands in source.
|
|
|
|
Examples:
|
|
powershell -ExecutionPolicy Bypass -File scripts/Deploy-Server.ps1 `
|
|
-DeployHost myhost -StackDir ~/docker/opennest
|
|
powershell -ExecutionPolicy Bypass -File scripts/Deploy-Server.ps1 `
|
|
-DeployHost myhost -StackDir ~/docker/opennest `
|
|
-LocalComposeFile D:\path\to\config-repo\opennest\compose.yml
|
|
#>
|
|
|
|
Param(
|
|
[Parameter(Mandatory)]
|
|
[string]$DeployHost,
|
|
[string]$DeployUser = "aj",
|
|
# Directory on the host that holds the stack's compose.yml.
|
|
[Parameter(Mandatory)]
|
|
[string]$StackDir,
|
|
[string]$ImageName = "opennest-server",
|
|
# Defaults to yyyyMMdd-<short sha>[-dirty].
|
|
[string]$Tag,
|
|
[string]$ContainerName = "opennest",
|
|
[string]$VolumeName = "opennest-data",
|
|
# Volume backups go to <BackupRoot>/opennest-<tag>/backup/ on the host.
|
|
[string]$BackupRoot = "~/deploy",
|
|
# Optional local copy of the stack's compose.yml (e.g. a config repo) to
|
|
# update with the new tag; it is edited, not committed.
|
|
[string]$LocalComposeFile,
|
|
[int]$HealthTimeoutSeconds = 120,
|
|
[switch]$SkipBackup,
|
|
# Skip --pull so cached base images are used.
|
|
[switch]$NoPull
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$ScriptDir = Split-Path -Parent $PSCommandPath
|
|
$RepoRoot = if ((Split-Path -Leaf $ScriptDir) -eq 'scripts') { Split-Path -Parent $ScriptDir } else { $ScriptDir }
|
|
Write-Host "Repository root: $RepoRoot"
|
|
|
|
$Dockerfile = Join-Path $RepoRoot 'OpenNest.Server\Dockerfile'
|
|
if (-not (Test-Path -LiteralPath $Dockerfile)) { throw "Dockerfile not found at: $Dockerfile" }
|
|
|
|
foreach ($tool in 'docker', 'git', 'ssh', 'scp') {
|
|
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "$tool was not found on PATH" }
|
|
}
|
|
|
|
$Revision = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
|
if ($LASTEXITCODE -ne 0) { throw "git rev-parse failed" }
|
|
$ShortRevision = $Revision.Substring(0, 8)
|
|
$Branch = (& git -C $RepoRoot rev-parse --abbrev-ref HEAD).Trim()
|
|
|
|
# Only the directories the Dockerfile copies affect the image.
|
|
$ServerSources = 'OpenNest.Server', 'OpenNest.Data', 'OpenNest.Core'
|
|
$Dirty = & git -C $RepoRoot status --porcelain -- $ServerSources
|
|
if ($Dirty) {
|
|
Write-Warning "Uncommitted changes in server sources are included in this build:"
|
|
$Dirty | ForEach-Object { Write-Warning " $_" }
|
|
}
|
|
|
|
if (-not $Tag) {
|
|
$Tag = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd'), $ShortRevision
|
|
if ($Dirty) { $Tag += "-dirty" }
|
|
}
|
|
$Image = "${ImageName}:$Tag"
|
|
$Remote = "${DeployUser}@${DeployHost}"
|
|
Write-Host "Building $Image from $Branch @ $ShortRevision"
|
|
|
|
$buildArgs = @('build', '-f', $Dockerfile, '-t', $Image, '--build-arg', "SOURCE_REVISION=$Revision")
|
|
if (-not $NoPull) { $buildArgs += '--pull' }
|
|
$buildArgs += $RepoRoot
|
|
& docker @buildArgs
|
|
if ($LASTEXITCODE -ne 0) { throw "docker build failed with exit code $LASTEXITCODE" }
|
|
|
|
$WorkDir = Join-Path ([IO.Path]::GetTempPath()) "opennest-server-deploy-$Tag"
|
|
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
|
$ImageTar = Join-Path $WorkDir "$ImageName-$Tag.tar"
|
|
$RemoteScriptPath = Join-Path $WorkDir 'deploy.sh'
|
|
|
|
# PowerShell 5.1 pipes between native commands as text, so save to a file
|
|
# instead of streaming docker save into ssh.
|
|
Write-Host "Saving image to $ImageTar..."
|
|
& docker save -o $ImageTar $Image
|
|
if ($LASTEXITCODE -ne 0) { throw "docker save failed with exit code $LASTEXITCODE" }
|
|
|
|
$remoteScript = @'
|
|
set -euo pipefail
|
|
tar_file=$1; image_name=$2; tag=$3; stack_dir=$4; container=$5; volume=$6
|
|
backup_root=$7; skip_backup=$8; health_timeout=$9
|
|
image="$image_name:$tag"
|
|
expand() { case $1 in "~"|"~/"*) printf '%s\n' "$HOME${1#\~}";; *) printf '%s\n' "$1";; esac; }
|
|
stack_dir=$(expand "$stack_dir"); backup_root=$(expand "$backup_root")
|
|
|
|
echo "Loading $image..."
|
|
docker load -i "$tar_file"
|
|
rm -f "$tar_file"
|
|
|
|
cd "$stack_dir"
|
|
previous=$(sed -nE "s/^[[:space:]]*image:[[:space:]]*$image_name:([^[:space:]]+).*/\1/p" compose.yml | head -n1)
|
|
[ -n "$previous" ] || { echo "No 'image: $image_name:<tag>' line in $stack_dir/compose.yml" >&2; exit 1; }
|
|
echo "Current tag: $previous"
|
|
|
|
backup_dir=
|
|
if [ "$skip_backup" != "1" ]; then
|
|
backup_dir="$backup_root/opennest-$tag/backup"
|
|
mkdir -p "$backup_dir"
|
|
cp compose.yml "$backup_dir/compose.yml.before"
|
|
fi
|
|
|
|
echo "Stopping $container..."
|
|
docker compose stop
|
|
|
|
if [ -n "$backup_dir" ]; then
|
|
echo "Backing up volume $volume to $backup_dir..."
|
|
docker run --rm --user 0 --entrypoint tar \
|
|
-v "$volume":/data:ro -v "$backup_dir":/backup \
|
|
"$image" czf "/backup/$volume.tar.gz" -C /data .
|
|
ls -lh "$backup_dir"
|
|
fi
|
|
|
|
sed -i -E "s|^([[:space:]]*image:[[:space:]]*)$image_name:[^[:space:]]+|\1$image|" compose.yml
|
|
docker compose up -d
|
|
|
|
echo "Waiting for $container to report healthy..."
|
|
deadline=$((SECONDS + health_timeout))
|
|
status=
|
|
while [ $SECONDS -lt $deadline ]; do
|
|
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container" 2>/dev/null || true)
|
|
[ "$status" = healthy ] && break
|
|
sleep 2
|
|
done
|
|
|
|
if [ "$status" != healthy ]; then
|
|
echo "$container did not become healthy (status: ${status:-unknown}). Recent logs:" >&2
|
|
docker logs --tail 50 "$container" >&2 || true
|
|
echo >&2
|
|
echo "Roll back: docker compose stop; restore $backup_dir/$volume.tar.gz into $volume;" >&2
|
|
echo "set the compose image back to $image_name:$previous; docker compose up -d" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "Deployed $image (previous: $previous)."
|
|
[ -z "$backup_dir" ] || echo "Volume backup: $backup_dir/$volume.tar.gz"
|
|
'@
|
|
# bash needs LF line endings; write without a BOM.
|
|
[IO.File]::WriteAllText($RemoteScriptPath, ($remoteScript -replace "`r`n", "`n"), (New-Object Text.UTF8Encoding $false))
|
|
|
|
try {
|
|
$RemoteTar = "/tmp/$ImageName-$Tag.tar"
|
|
$RemoteScript = "/tmp/opennest-server-deploy-$Tag.sh"
|
|
Write-Host "Copying image to ${Remote}..."
|
|
& scp $ImageTar "${Remote}:$RemoteTar"
|
|
if ($LASTEXITCODE -ne 0) { throw "scp of the image failed" }
|
|
& scp $RemoteScriptPath "${Remote}:$RemoteScript"
|
|
if ($LASTEXITCODE -ne 0) { throw "scp of the deploy script failed" }
|
|
|
|
$skip = if ($SkipBackup) { '1' } else { '0' }
|
|
& ssh $Remote bash $RemoteScript $RemoteTar $ImageName $Tag "'$StackDir'" $ContainerName $VolumeName "'$BackupRoot'" $skip $HealthTimeoutSeconds
|
|
$deployExit = $LASTEXITCODE
|
|
& ssh $Remote rm -f $RemoteScript
|
|
if ($deployExit -ne 0) { throw "Remote deploy failed with exit code $deployExit (see output above)" }
|
|
}
|
|
finally {
|
|
Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
if ($LocalComposeFile) {
|
|
$compose = Get-Content -LiteralPath $LocalComposeFile -Raw
|
|
$updated = [regex]::Replace($compose, "(?m)^(\s*image:\s*)$([regex]::Escape($ImageName)):\S+", "`${1}$Image")
|
|
if ($updated -eq $compose) {
|
|
Write-Warning "No '$ImageName' image line changed in $LocalComposeFile"
|
|
}
|
|
else {
|
|
[IO.File]::WriteAllText($LocalComposeFile, $updated, (New-Object Text.UTF8Encoding $false))
|
|
Write-Host "Updated $LocalComposeFile to $Image (not committed)."
|
|
}
|
|
}
|
|
|
|
Write-Host "Done: $Image is running on $DeployHost."
|