#!/usr/bin/env bash # Roll out a published CodeCraft image to a remote installation that was set up with # scripts/move-server.sh (docker-compose.yml + .env in one folder on the target). # # scripts/release.sh # build and publish a new version (amd64 + arm64) # scripts/deploy.sh # roll the newest tag out to the target # # Steps: # 1. check that the image exists in the registry for the target's architecture, # 2. wait until no agent or terminal runs on the target (unless --force), # 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d # (or start.sh on hosts without Compose), # 4. run scripts/smoke-test.sh on the target; on failure the previous version is # started again. # # Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force] # [--wait MINUTES] [--dry-run] # vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag) # --target installation folder on the target (default: DEPLOY_TARGET from .env) # --force restart even if agents or terminals are running (they end) # --wait N how long to wait for an idle server before giving up (default 15) # --dry-run show the steps only # # Environment: # REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft) # CONTAINER container name on the target (default codecraft) # PORT port of CodeCraft on the target, for the smoke test (default: PORT # from the target's .env, else 8787) set -euo pipefail REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}" CONTAINER="${CONTAINER:-codecraft}" tag="" target="" force=0 wait_min=15 dry=0 while (($#)); do case "$1" in --target) target="$2"; shift ;; --force) force=1 ;; --wait) wait_min="$2"; shift ;; --dry-run) dry=1 ;; -h|--help) sed -n '2,29p' "$0"; exit 0 ;; -*) echo "unknown option $1" >&2; exit 2 ;; *) tag="$1" ;; esac shift done cd "$(git rev-parse --show-toplevel)" step() { printf '\n==> %s\n' "$*"; } fail() { echo "error: $*" >&2; exit 1; } q() { printf '%q' "$1"; } env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; } target="${target:-$(env_get DEPLOY_TARGET || true)}" [[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env" host="${target%%:*}" dir="${target#*:}"; dir="${dir%/}" remote() { ssh -o BatchMode=yes "$host" "$1"; } run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; } if [[ -z "$tag" ]]; then git fetch --tags --quiet origin || true tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1) [[ -n "$tag" ]] || fail "no release tag found" fi [[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag" step "Checking $host:$dir and the image $REGISTRY:$tag" remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \ || fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh" current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1") case "$(remote 'uname -m')" in x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;; esac if [[ -n "$arch" ]]; then # Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE). manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true) grep -q "Platform:.*linux/$arch" <<<"$manifests" \ || fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first" fi echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})" [[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway" # Busy = the container runs more than the server process (agents' shells, terminals, ...). busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); } if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)" deadline=$(( $(date +%s) + wait_min * 60 )) while busy; do (( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force" sleep 15 done echo "idle" fi step "Updating to $tag" run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \ && (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \ && docker pull -q $(q "$REGISTRY:$tag") >/dev/null \ && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" step "Smoke test" port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}" if (( dry )); then echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}" else ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" &2 remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \ && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" fi fail "deploy of $tag failed" fi fi step "Done" echo "$host runs CodeCraft $tag."