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 <noreply@anthropic.com>
This commit is contained in:
1 parent
4244b9370c
commit
0241f6031b
2 files changed
+127
-5
No files matched your search
Executable
+117
@@ -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")" <scripts/smoke-test.sh
|
||||||
|
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
|
||||||
|
if [[ -n "$current" && "$current" != "$tag" ]]; then
|
||||||
|
echo "smoke test failed – going back to $current" >&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."
|
||||||
+10
-5
@@ -37,12 +37,15 @@
|
|||||||
# --source-container C container to stop here (default codecraft)
|
# --source-container C container to stop here (default codecraft)
|
||||||
# --source-volume V data volume here (default codecraft-data)
|
# --source-volume V data volume here (default codecraft-data)
|
||||||
# --source-workspace D project folder here (default: WORKSPACE of .env)
|
# --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)
|
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
|
||||||
# --dry-run show the steps only
|
# --dry-run show the steps only
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
|
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
|
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
||||||
|
excludes=()
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--workspace) ws_target="$2"; shift ;;
|
--workspace) ws_target="$2"; shift ;;
|
||||||
@@ -56,9 +59,10 @@ while (($#)); do
|
|||||||
--source-container) src_container="$2"; shift ;;
|
--source-container) src_container="$2"; shift ;;
|
||||||
--source-volume) src_volume="$2"; shift ;;
|
--source-volume) src_volume="$2"; shift ;;
|
||||||
--source-workspace) ws_source="$2"; shift ;;
|
--source-workspace) ws_source="$2"; shift ;;
|
||||||
|
--exclude) excludes+=(--exclude "$2"); shift ;;
|
||||||
--no-stop) stop=0 ;;
|
--no-stop) stop=0 ;;
|
||||||
--dry-run) dry=1 ;;
|
--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 ;;
|
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||||
*) target="$1" ;;
|
*) target="$1" ;;
|
||||||
esac
|
esac
|
||||||
@@ -68,7 +72,8 @@ done
|
|||||||
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
|
[[ -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%%:*}"
|
host="${target%%:*}"
|
||||||
dir="${target#*:}"
|
dir="${target#*:}"
|
||||||
@@ -93,9 +98,9 @@ remote() {
|
|||||||
}
|
}
|
||||||
q() { printf '%q' "$1"; }
|
q() { printf '%q' "$1"; }
|
||||||
sync_workspace() {
|
sync_workspace() {
|
||||||
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi
|
if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
|
||||||
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/"
|
if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
|
||||||
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
|
else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
|
||||||
}
|
}
|
||||||
put_file() { # put_file <remote path> < content
|
put_file() { # put_file <remote path> < content
|
||||||
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
|
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
|
||||||
|
|||||||
Reference in new issue
Block a user