chore(server): add a script that redeploys a development build over SSH

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>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-23 08:44:31 -04:00
1 parent 0785bb84c9
commit a00e2c3233
2 files changed
+224

No files matched your search

+31
View File
@@ -896,6 +896,37 @@ use **Tools > Options > Nest server URL > Set / Change Server**. This saves the
immediately, independently of the Options Save button; it does not migrate nests
or enable Database mode. Canceling Options afterward does not undo that address.
### Redeploying a development build
Until a verified image is published, `scripts/Deploy-Server.ps1` redeploys a
development build of this checkout to a host's Compose stack over SSH, using the
local Docker engine (no build on the host, no registry):
```powershell
powershell -ExecutionPolicy Bypass -File scripts/Deploy-Server.ps1 `
-DeployHost <host> -StackDir ~/docker/opennest `
-LocalComposeFile <config-repo>\opennest\compose.yml
```
It builds `opennest-server:<yyyyMMdd>-<short sha>` (`-dirty` and a warning when
`OpenNest.Server`, `OpenNest.Data` or `OpenNest.Core` have uncommitted changes;
`VERSION` stays `0.0.0-dev`, `SOURCE_REVISION` is the full commit), copies it with
`docker save`/`scp`/`docker load`, stops the container, tars the data volume to
`<BackupRoot>/opennest-<tag>/backup/` with the previous `compose.yml`, rewrites the
stack's `image: opennest-server:<tag>` line, starts the stack and waits for
`healthy`. The stack's `compose.yml` must name the image literally on that line,
not through a variable as `compose.server.yaml` does. `-LocalComposeFile` updates
a config repository's copy, uncommitted. Host, user and paths are parameters so
no private address is in source; `-SkipBackup` and `-NoPull` are available but
the backup is the only way back across a schema upgrade.
Build from a pushed commit (a clean worktree is simplest) so the running image
can be traced. When the build raises the schema version, first run the new image
in a throwaway loopback container on a snapshot of the live database and check
`user_version`, integrity and record counts. To roll back, stop the stack,
restore the tarball into the volume and set the printed previous tag: an older
server refuses a newer schema.
### Backup, restore, and upgrade
SQLite runs in WAL mode, so copying `nests.db` from a running service is not a
+193
View File
@@ -0,0 +1,193 @@
<#
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."