#!/usr/bin/env bash # Deploy doctate-server to a remote Docker host (default: minerva.lan). # Idempotent: re-runs preserve operator-owned config, data, logs. # # Usage: ./scripts/deploy-server.sh [host] # host default: minerva.lan # # See docs/deployment.md for first-time bootstrap. set -euo pipefail HOST="${1:-minerva.lan}" REMOTE="/opt/stacks/doctate-server" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" GIT_SHA="$(cd "$REPO_ROOT" && git rev-parse --short HEAD)" IMAGE_LATEST="doctate-server:latest" IMAGE_TAGGED="doctate-server:$GIT_SHA" cd "$REPO_ROOT" # ─── Phase 0 ─── local pre-flight echo ">>> [0/10] cargo fmt --check (server)" ( cd server && cargo fmt --check ) if ! git -C "$REPO_ROOT" diff --quiet || ! git -C "$REPO_ROOT" diff --cached --quiet; then echo " WARNING: working tree is dirty — building from on-disk state, not from $GIT_SHA" >&2 fi # ─── Phase 1 ─── remote bootstrap (idempotent) echo ">>> [1/10] Ensuring remote layout at $HOST:$REMOTE" ssh "$HOST" "mkdir -p $REMOTE/config $REMOTE/data $REMOTE/logs" # ─── Phase 2 ─── seed templates only if missing echo ">>> [2/10] Seeding config templates (only if missing)" for pair in \ ".env.example:.env" \ "settings.toml.example:settings.toml" \ "users.toml.example:users.toml" do src="server/${pair%%:*}" dst="${pair##*:}" remote_path="$REMOTE/config/$dst" if ssh "$HOST" "test -f $remote_path"; then echo " KEEP $remote_path" else echo " SEED $remote_path <- $src" scp -q "$src" "$HOST:$remote_path" fi done # ─── Phase 3 ─── placeholder guard echo ">>> [3/10] Verifying configs have been edited from defaults" if ssh "$HOST" "grep -qE '\\\$2b\\\$12\\\$\\.\\.\\.|change-me-to-a-secure-key' $REMOTE/config/users.toml" 2>/dev/null; then cat >&2 <>> [4/10] Building $IMAGE_TAGGED + $IMAGE_LATEST" DOCKER_BUILDKIT=1 docker build \ -f server/Dockerfile \ -t "$IMAGE_TAGGED" \ -t "$IMAGE_LATEST" \ . # ─── Phase 5 ─── stream image to host echo ">>> [5/10] Transferring image to $HOST (gzip-streamed via ssh)" docker save "$IMAGE_TAGGED" "$IMAGE_LATEST" | gzip | ssh "$HOST" 'gunzip | docker load' # ─── Phase 6 ─── compose file sync echo ">>> [6/10] Syncing compose file" scp -q deploy/doctate-server.compose.yml "$HOST:$REMOTE/compose.yaml" # ─── Phase 7 ─── restart echo ">>> [7/10] (Re)starting container" ssh "$HOST" "cd $REMOTE && docker compose up -d --force-recreate" # ─── Phase 8 ─── health poll echo ">>> [8/10] Waiting for /api/health" for i in $(seq 1 20); do if curl -fsS "http://$HOST:3000/api/health" >/dev/null 2>&1; then echo " OK after ${i} attempt(s)" # ─── Phase 9 ─── prune old image tags (keep last KEEP + :latest) # Containers hold images via internal IDs, not tags, so removing # an old -tag never affects the running container as long as # :latest still points to it. KEEP=5 echo ">>> [9/10] Pruning old image tags on $HOST (keep last $KEEP)" ssh "$HOST" " set -e docker images doctate-server --format '{{.CreatedAt}}\t{{.Tag}}' | \ grep -v -P '\\tlatest\$' | \ sort -r | \ awk 'NR > $KEEP { print \$NF }' | \ xargs -I{} -r docker rmi doctate-server:{} 2>/dev/null || true docker image prune -f >/dev/null 2>&1 || true " # ─── Phase 10 ─── final echo echo ">>> [10/10] Deployed $GIT_SHA" echo echo "Rollback:" echo " ssh $HOST 'docker tag doctate-server: $IMAGE_LATEST && cd $REMOTE && docker compose up -d --force-recreate'" exit 0 fi sleep 3 done # health timeout echo ">>> TIMEOUT (60s) — last 50 lines of container logs:" >&2 ssh "$HOST" "docker logs --tail 50 doctate-server" >&2 || true exit 1