From 0241f6031b7c5d4edb2deea4e4b16fa9cb6a036a Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 9 Nov 2026 19:46:16 +0100 Subject: [PATCH] Deploy script for remote installations; move-server: --exclude, quoted .env values scripts/deploy.sh rolls a published image out to an installation set up with move-server.sh (DEPLOY_TARGET in .env): checks the image for the target's architecture, waits until no agent or terminal runs (--force skips), pulls and recreates, runs the smoke test and goes back to the previous version on failure. move-server.sh: --exclude leaves paths out of the workspace copy; values in .env may be quoted (WORKSPACE='/home/...'). Co-Authored-By: Claude Opus 5.5 --- scripts/deploy.sh | 117 +++++++++++++++++++++++++++++++++++++++++ scripts/move-server.sh | 15 ++++-- 2 files changed, 127 insertions(+), 5 deletions(-) create mode 100755 scripts/deploy.sh diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..20a79c0 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,117 @@ +#!/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 + docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null | grep -q "Platform:.*linux/$arch" \ + || 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." diff --git a/scripts/move-server.sh b/scripts/move-server.sh index c403b3e..d80123b 100755 --- a/scripts/move-server.sh +++ b/scripts/move-server.sh @@ -37,12 +37,15 @@ # --source-container C container to stop here (default codecraft) # --source-volume V data volume here (default codecraft-data) # --source-workspace D project folder here (default: WORKSPACE of .env) +# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the +# workspace, e.g. /old-project/ or /app/target/); repeatable # --no-stop do not stop the source (only for tests – the copy may be inconsistent) # --dry-run show the steps only set -euo pipefail target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft" src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0 +excludes=() while (($#)); do case "$1" in --workspace) ws_target="$2"; shift ;; @@ -56,9 +59,10 @@ while (($#)); do --source-container) src_container="$2"; shift ;; --source-volume) src_volume="$2"; shift ;; --source-workspace) ws_source="$2"; shift ;; + --exclude) excludes+=(--exclude "$2"); shift ;; --no-stop) stop=0 ;; --dry-run) dry=1 ;; - -h|--help) sed -n '2,36p' "$0"; exit 0 ;; + -h|--help) sed -n '2,38p' "$0"; exit 0 ;; -*) echo "unknown option $1" >&2; exit 2 ;; *) target="$1" ;; esac @@ -68,7 +72,8 @@ done cd "$(git rev-parse --show-toplevel)" [[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; } -env_get() { sed -n "s/^$1=//p" .env | tail -1; } +# Value of a .env variable, without surrounding quotes. +env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; } host="${target%%:*}" dir="${target#*:}" @@ -93,9 +98,9 @@ remote() { } q() { printf '%q' "$1"; } sync_workspace() { - if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi - if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/" - else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi + if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi + if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/" + else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi } put_file() { # put_file < content if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi