deploy/update.sh
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# deploy/update.sh — one-click Docker update for WindsurfAPI.
#
# Pull (or build) a new image, recreate only the application container, wait
# for GET /health, and automatically restore the image that was running before
# when the new one fails. The data volume is never touched: this script never
# runs `docker compose down`, never passes `-v`/`--volumes`, and never removes
# containers or volumes.
#
# Usage:
# bash deploy/update.sh # pull ghcr.io/dwgx/windsurf-api:latest
# bash deploy/update.sh --tag 3.9.52 # pull a pinned release tag
# bash deploy/update.sh --image REF # pull an arbitrary image reference
# bash deploy/update.sh --build # rebuild from this checkout instead
# bash deploy/update.sh --rollback # restore the last rollback point
# bash deploy/update.sh --dry-run # print the plan; change nothing
#
# Options:
# --tag <tag> Pin ghcr.io/dwgx/windsurf-api:<tag> (v prefix optional).
# --image <ref> Full image reference, e.g. a fork or a digest.
# --build `docker compose build` from this checkout, then update.
# --rollback Recreate the app from the saved rollback point and wait
# for /health. Does not pull.
# --timeout <secs> Health gate timeout (default 120; 0 = one attempt).
# --dry-run Preflight + print the exact plan. No pull, no build,
# no tag, no recreate, no state writes.
# -h, --help Show this help.
#
# Exit codes:
# 0 update succeeded and /health passed (or --dry-run preflight passed)
# 1 update failed; the previous image is running again (or was untouched)
# 2 update failed and the rollback also failed — manual intervention needed
#
# Environment (all optional):
# WINDSURFAPI_IMAGE Image reference; also read by docker-compose.yml.
# WINDSURFAPI_COMPOSE_FILE Compose file (default <repo>/docker-compose.yml).
# WINDSURFAPI_PROJECT_DIR Compose project directory, used to resolve
# relative bind mounts (default <repo>).
# WINDSURFAPI_SERVICE App service name (default windsurf-api).
# WINDSURFAPI_LB_SERVICE Load-balancer service for the /health URL
# (default nginx; falls back to the app container).
# WINDSURFAPI_UPDATE_STATE_DIR Rollback point + lock directory
# (default <repo>/.docker-data/update).
# WINDSURFAPI_HEALTH_TIMEOUT Seconds to wait for /health (default 120).
# WINDSURFAPI_HEALTH_INTERVAL Seconds between probes (default 2).
# WINDSURFAPI_DOCKER Docker binary (default docker).
#
# The script never reads or prints .env, registry credentials, API keys or
# account data. Registry login is whatever `docker` already has.
#
# See docs/DOCKER-UPDATE.md for the operator-facing walkthrough.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)"
DEFAULT_IMAGE_REPO="ghcr.io/dwgx/windsurf-api"
DEFAULT_IMAGE="${DEFAULT_IMAGE_REPO}:latest"
COMPOSE_FILE="${WINDSURFAPI_COMPOSE_FILE:-$ROOT_DIR/docker-compose.yml}"
PROJECT_DIR="${WINDSURFAPI_PROJECT_DIR:-$ROOT_DIR}"
SERVICE="${WINDSURFAPI_SERVICE:-windsurf-api}"
LB_SERVICE="${WINDSURFAPI_LB_SERVICE:-nginx}"
STATE_DIR="${WINDSURFAPI_UPDATE_STATE_DIR:-$ROOT_DIR/.docker-data/update}"
STATE_FILE="$STATE_DIR/rollback-point.env"
SUCCESS_FILE="$STATE_DIR/last-success.env"
LOCK_DIR="$STATE_DIR/lock"
HEALTH_TIMEOUT="${WINDSURFAPI_HEALTH_TIMEOUT:-120}"
HEALTH_INTERVAL="${WINDSURFAPI_HEALTH_INTERVAL:-2}"
PROBE_TIMEOUT="${WINDSURFAPI_PROBE_TIMEOUT:-5}"
DOCKER_BIN="${WINDSURFAPI_DOCKER:-docker}"
MODE="pull"
TAG=""
IMAGE_OVERRIDE=""
DRY_RUN=0
# Captured before the update; used by the rollback path.
CID=""
OLD_ID=""
OLD_REF=""
ACTIVE=""
COMPOSE_IMAGE=""
FIRST_INSTALL=0
PROBE_BODY=""
usage() {
sed -n '2,/^set -uo pipefail$/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
}
log() { printf '%s\n' "$*"; }
err() { printf 'ERROR: %s\n' "$*" >&2; }
die() { err "$*"; exit 1; }
# One definition of the compose invocation, shared by `compose` and the
# exec-fallback probe (which must run it under a timeout).
COMPOSE_CMD=("$DOCKER_BIN" compose --project-directory "$PROJECT_DIR" -f "$COMPOSE_FILE")
compose() {
"${COMPOSE_CMD[@]}" "$@"
}
compose_display() {
printf 'docker compose --project-directory %s -f %s' "$PROJECT_DIR" "$COMPOSE_FILE"
}
validate_tag() {
[[ "$1" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] \
|| die "invalid tag '$1' (Docker tags allow letters, digits, '_', '.', '-')"
}
validate_image_ref() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]] \
|| die "invalid image reference '$1'"
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
--pull) MODE="pull" ;;
--build) MODE="build" ;;
--rollback) MODE="rollback" ;;
--tag)
[ $# -ge 2 ] || die "--tag requires a value"
[ -n "$2" ] || die "--tag requires a non-empty value"
TAG="$2"; shift
;;
--tag=*)
TAG="${1#*=}"
[ -n "$TAG" ] || die "--tag requires a non-empty value"
;;
--image)
[ $# -ge 2 ] || die "--image requires a value"
[ -n "$2" ] || die "--image requires a non-empty value"
IMAGE_OVERRIDE="$2"; shift
;;
--image=*)
IMAGE_OVERRIDE="${1#*=}"
[ -n "$IMAGE_OVERRIDE" ] || die "--image requires a non-empty value"
;;
--timeout)
[ $# -ge 2 ] || die "--timeout requires a value"
HEALTH_TIMEOUT="$2"; shift
;;
--timeout=*) HEALTH_TIMEOUT="${1#*=}" ;;
--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option '$1' (try --help)" ;;
esac
shift
done
[ -n "$TAG" ] && [ -n "$IMAGE_OVERRIDE" ] && die "--tag and --image are mutually exclusive"
if [ "$MODE" = "rollback" ] && { [ -n "$TAG" ] || [ -n "$IMAGE_OVERRIDE" ]; }; then
die "--rollback cannot be combined with --tag/--image"
fi
[[ "$HEALTH_TIMEOUT" =~ ^[0-9]+$ ]] || die "--timeout must be a non-negative integer"
[[ "$HEALTH_INTERVAL" =~ ^[0-9]+([.][0-9]+)?$ ]] || die "WINDSURFAPI_HEALTH_INTERVAL must be a number"
[[ "$PROBE_TIMEOUT" =~ ^[0-9]+$ ]] || die "WINDSURFAPI_PROBE_TIMEOUT must be an integer"
if [ -n "$TAG" ]; then
validate_tag "$TAG"
# Accept the GitHub release spelling (v3.9.52) but keep tags that merely
# start with a letter (e.g. "v2-custom" stays "v2-custom").
if [[ "$TAG" =~ ^v[0-9] ]]; then
TAG="${TAG#v}"
validate_tag "$TAG"
fi
fi
[ -n "$IMAGE_OVERRIDE" ] && validate_image_ref "$IMAGE_OVERRIDE"
}
preflight() {
command -v "$DOCKER_BIN" >/dev/null 2>&1 \
|| die "docker is not installed or not on PATH (looked for '$DOCKER_BIN')"
[ -f "$COMPOSE_FILE" ] \
|| die "compose file not found: $COMPOSE_FILE (set WINDSURFAPI_COMPOSE_FILE)"
compose version >/dev/null 2>&1 \
|| die "docker compose v2 is required ('docker compose version' failed)"
"$DOCKER_BIN" info >/dev/null 2>&1 \
|| die "cannot reach the Docker daemon (start Docker or fix socket permissions)"
compose config --quiet >/dev/null 2>&1 \
|| die "docker compose rejected $COMPOSE_FILE"
if { [ -n "$TAG" ] || [ -n "$IMAGE_OVERRIDE" ]; } \
&& ! grep -q 'WINDSURFAPI_IMAGE' "$COMPOSE_FILE" 2>/dev/null; then
die "$COMPOSE_FILE hardcodes image:; --tag/--image need the \${WINDSURFAPI_IMAGE:-...} form"
fi
}
acquire_lock() {
mkdir -p "$STATE_DIR" || die "cannot create state directory $STATE_DIR"
if mkdir "$LOCK_DIR" 2>/dev/null; then
printf '%s\n' "$$" > "$LOCK_DIR/pid"
return 0
fi
local pid=""
if [ -r "$LOCK_DIR/pid" ]; then
read -r pid < "$LOCK_DIR/pid" || true
else
# A crash between `mkdir` and writing the pid leaves no proof of death.
# Fail closed instead of stealing a lock that may be live.
die "lock exists without an owner pid; inspect and remove $LOCK_DIR manually"
fi
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
die "another update is already running (pid $pid, lock $LOCK_DIR)"
fi
# The owner is provably gone. Move the stale directory aside atomically so
# only one contender can recover it.
local quarantine="$LOCK_DIR.stale-$$-$RANDOM" moved_pid=""
if ! mv "$LOCK_DIR" "$quarantine" 2>/dev/null; then
die "could not recover the stale update lock at $LOCK_DIR"
fi
if [ -r "$quarantine/pid" ]; then
read -r moved_pid < "$quarantine/pid" || true
fi
if [ "$moved_pid" != "$pid" ]; then
# Another contender recovered and recreated the lock between our read and
# the rename. Put it back rather than deleting a live owner's lock.
mv "$quarantine" "$LOCK_DIR" 2>/dev/null || true
die "update lock changed while recovering; retry"
fi
rm -f "$quarantine/pid"
rmdir "$quarantine" 2>/dev/null || true
mkdir "$LOCK_DIR" 2>/dev/null \
|| die "could not create the update lock at $LOCK_DIR"
printf '%s\n' "$$" > "$LOCK_DIR/pid"
}
release_lock() {
[ -d "$LOCK_DIR" ] || return 0
local pid=""
if [ -r "$LOCK_DIR/pid" ]; then
read -r pid < "$LOCK_DIR/pid" || true
fi
[ "$pid" = "$$" ] || return 0
rm -f "$LOCK_DIR/pid"
rmdir "$LOCK_DIR" 2>/dev/null || true
}
current_container() {
compose ps -q "$SERVICE" 2>/dev/null | head -n1
}
inspect_field() {
"$DOCKER_BIN" inspect --format "$1" "$2" 2>/dev/null || true
}
compose_image() {
local yaml picked
yaml="$(compose config 2>/dev/null)" || return 1
[ -n "$yaml" ] || return 1
# Resolve the image of THIS service, not "the first image in the file":
# docker compose config --images lists nginx first, so a fork image without
# "windsurf" in its name would otherwise be mistaken for the load balancer.
picked="$(printf '%s\n' "$yaml" | tr -d '\r' | awk -v svc="$SERVICE" '
$0 == " " svc ":" { inside = 1; next }
inside && /^ [^ ].*:$/ { exit }
inside && /^ image:/ { sub(/^ image:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit }
')"
[ -n "$picked" ] || return 1
printf '%s' "$picked"
}
capture_current() {
CID="$(current_container)"
OLD_ID=""
OLD_REF=""
if [ -n "$CID" ]; then
OLD_ID="$(inspect_field '{{.Image}}' "$CID")"
OLD_REF="$(inspect_field '{{.Config.Image}}' "$CID")"
else
FIRST_INSTALL=1
fi
COMPOSE_IMAGE="$(compose_image || true)"
}
resolve_active_image() {
local explicit=0
if [ -n "$IMAGE_OVERRIDE" ]; then
ACTIVE="$IMAGE_OVERRIDE"
explicit=1
elif [ -n "$TAG" ]; then
ACTIVE="$DEFAULT_IMAGE_REPO:$TAG"
explicit=1
elif [ -n "${WINDSURFAPI_IMAGE:-}" ]; then
validate_image_ref "$WINDSURFAPI_IMAGE"
ACTIVE="$WINDSURFAPI_IMAGE"
explicit=1
elif [ -n "$OLD_REF" ]; then
ACTIVE="$OLD_REF"
elif [ -n "$COMPOSE_IMAGE" ]; then
ACTIVE="$COMPOSE_IMAGE"
else
ACTIVE="$DEFAULT_IMAGE"
fi
# Export only when we are overriding Compose or preserving the reference the
# running container was created from. On a first install with no flags, let
# Compose resolve its own image (a fork may not use the default repo at all).
if [ "$explicit" = "1" ] || [ -n "$OLD_REF" ]; then
export WINDSURFAPI_IMAGE="$ACTIVE"
fi
}
short_id() {
local id="$1"
id="${id#sha256:}"
printf '%s' "${id:0:12}"
}
write_state() {
local file="$1" id="$2" ref="$3" extra="${4:-}"
mkdir -p "$STATE_DIR" || return 1
{
printf 'image_id=%s\n' "$id"
printf 'image_ref=%s\n' "$ref"
printf 'saved_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
if [ -n "$extra" ]; then
printf '%s\n' "$extra"
fi
} > "$file.tmp" || return 1
mv "$file.tmp" "$file"
}
read_rollback_point() {
[ -r "$STATE_FILE" ] || return 1
ROLLBACK_ID="$(sed -n 's/^image_id=//p' "$STATE_FILE" | head -n1)"
ROLLBACK_REF="$(sed -n 's/^image_ref=//p' "$STATE_FILE" | head -n1)"
[[ "$ROLLBACK_ID" =~ ^sha256:[0-9a-fA-F]{64}$ ]] || return 1
[ -n "$ROLLBACK_REF" ] || return 1
}
published_url() {
local service="$1" port="$2" published host hostport
published="$(compose port "$service" "$port" 2>/dev/null | head -n1)"
[ -n "$published" ] || return 1
host="${published%:*}"
hostport="${published##*:}"
host="${host#[}"
host="${host%]}"
case "$host" in
""|"0.0.0.0"|"::"|"*") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/health' "$host" "$hostport"
}
resolve_health_url() {
published_url "$LB_SERVICE" 80 && return 0
published_url "$SERVICE" 3003 && return 0
return 1
}
body_is_ok() {
printf '%s' "$PROBE_BODY" | tr -d '[:space:]' | grep -q '"status":"ok"'
}
json_field() {
printf '%s' "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p"
}
# Run a command with a hard wall-clock limit. Uses timeout(1) when present
# (Linux, busybox); macOS has no timeout in the base system, so fall back to a
# background watchdog. Returns 124 when the limit is hit, matching timeout(1).
# A zero limit disables the bound (same convention as curl --max-time).
run_with_timeout() {
local seconds="$1" pid ticks limit out status
shift
if [ "$seconds" -eq 0 ] 2>/dev/null; then
"$@"
return $?
fi
if command -v timeout >/dev/null 2>&1; then
timeout "$seconds" "$@"
return $?
fi
# Watchdog fallback. Capture into a file, not the caller's pipe: a killed
# child may leave a grandchild holding stdout, and the command substitution
# would then wait for that fd even though the bound already fired.
out="$(mktemp "${TMPDIR:-/tmp}/windsurfapi-probe.XXXXXX")" || return 1
"$@" >"$out" 2>/dev/null &
pid=$!
ticks=0
limit=$((seconds * 10))
while kill -0 "$pid" 2>/dev/null; do
if [ "$ticks" -ge "$limit" ]; then
kill -TERM "$pid" 2>/dev/null || true
sleep 0.2
kill -KILL "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
rm -f "$out"
return 124
fi
sleep 0.1
ticks=$((ticks + 1))
done
wait "$pid"
status=$?
cat "$out"
rm -f "$out"
return "$status"
}
probe_exec() {
local fetch_signal=""
# The shell-level bound below is what saves us when `compose exec` itself is
# wedged (daemon/CLI hang). The AbortSignal bounds the in-container fetch when
# the app's event loop is wedged but the exec still starts. Both are needed:
# the gate exists precisely for a container that is running but unresponsive.
if [ "$PROBE_TIMEOUT" -gt 0 ]; then
fetch_signal=",{signal:AbortSignal.timeout($((PROBE_TIMEOUT * 1000)))}"
fi
PROBE_BODY="$(run_with_timeout "$PROBE_TIMEOUT" "${COMPOSE_CMD[@]}" exec -T "$SERVICE" \
node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3003)+'/health'${fetch_signal}).then(async r=>{const t=await r.text();if(!r.ok)process.exit(1);process.stdout.write(t)}).catch(()=>process.exit(1))" \
2>/dev/null)" || return 1
body_is_ok
}
probe_once() {
local url="$1"
PROBE_BODY=""
if [ -n "$url" ]; then
if command -v curl >/dev/null 2>&1; then
PROBE_BODY="$(curl -fsS --max-time "$PROBE_TIMEOUT" "$url" 2>/dev/null)" || return 1
body_is_ok && return 0
return 1
fi
if command -v wget >/dev/null 2>&1; then
PROBE_BODY="$(wget -q -O - --timeout="$PROBE_TIMEOUT" "$url" 2>/dev/null)" || return 1
body_is_ok && return 0
return 1
fi
if command -v node >/dev/null 2>&1; then
PROBE_BODY="$(run_with_timeout "$PROBE_TIMEOUT" node -e "fetch(process.argv[1]).then(async r=>{const t=await r.text();if(!r.ok)process.exit(1);process.stdout.write(t)}).catch(()=>process.exit(1))" "$url" 2>/dev/null)" || return 1
body_is_ok && return 0
return 1
fi
fi
probe_exec
}
container_state() {
local cid state
cid="$(current_container)"
if [ -z "$cid" ]; then
printf 'missing'
return 0
fi
state="$(inspect_field '{{.State.Status}}{{if .State.Health}} {{.State.Health.Status}}{{end}}' "$cid")"
printf '%s' "${state:-unknown}"
}
health_gate() {
local url deadline last_state="" state now
url="$(resolve_health_url || true)"
deadline=$(( $(date +%s) + HEALTH_TIMEOUT ))
if [ -n "$url" ]; then
log "Waiting for $url (${HEALTH_TIMEOUT}s timeout)..."
else
log "Waiting for /health inside the $SERVICE container (${HEALTH_TIMEOUT}s timeout)..."
fi
while :; do
if probe_once "$url"; then
return 0
fi
state="$(container_state)"
if [ "$state" != "$last_state" ]; then
log " container: $state"
last_state="$state"
fi
case "$state" in
exited*|dead*|missing)
return 1
;;
esac
now=$(date +%s)
[ "$now" -ge "$deadline" ] && return 1
sleep "$HEALTH_INTERVAL"
done
}
health_failure_detail() {
if [ -n "$PROBE_BODY" ]; then
log " last response: $(printf '%s' "$PROBE_BODY" | tr '\n' ' ' | cut -c1-240)"
fi
log " container state: $(container_state)"
log " logs: $(compose_display) logs --tail=80 $SERVICE"
}
print_health_ok() {
local version commit
version="$(json_field "$PROBE_BODY" version)"
commit="$(json_field "$PROBE_BODY" commit)"
[ -n "$version" ] && log " /health version: $version"
[ -n "$commit" ] && log " /health commit: $commit"
}
rollback_target_ref() {
local ref="$1"
if [ -z "$ref" ] || [[ "$ref" == *"@"* ]]; then
printf '%s:rollback' "$DEFAULT_IMAGE_REPO"
else
printf '%s' "$ref"
fi
}
restore_image() {
local id="$1" ref="$2" target
target="$(rollback_target_ref "$ref")"
log "Rolling back to $target ($(short_id "$id"))..."
"$DOCKER_BIN" tag "$id" "$target" >/dev/null 2>&1 || {
err "could not tag $target as the previous image ($id)"
return 1
}
export WINDSURFAPI_IMAGE="$target"
if ! compose up -d --no-deps --no-build --force-recreate "$SERVICE"; then
err "rollback recreate failed"
return 1
fi
return 0
}
rollback_and_exit() {
local reason="$1" failed_id="${2:-}" target
log "UPDATE FAILED: $reason"
if [ -z "$OLD_ID" ]; then
err "no previous image was recorded (first install), so there is nothing to roll back to."
health_failure_detail
log "Fix the cause, then retry: bash deploy/update.sh${TAG:+ --tag $TAG}"
exit 2
fi
target="$(rollback_target_ref "$OLD_REF")"
if ! restore_image "$OLD_ID" "$OLD_REF"; then
health_failure_detail
err "automatic rollback failed. Manual recovery:"
log " $DOCKER_BIN tag $OLD_ID $target"
log " WINDSURFAPI_IMAGE=$target $(compose_display) up -d --no-deps --force-recreate $SERVICE"
exit 2
fi
if health_gate; then
log "ROLLED BACK: the previous image is running and /health passed."
print_health_ok
if [ -n "$failed_id" ]; then
log "The failed image is still on disk for diagnosis:"
log " $DOCKER_BIN image inspect $failed_id"
fi
exit 1
fi
err "the rollback container is also unhealthy; manual intervention is required."
health_failure_detail
exit 2
}
manual_rollback() {
local target
if ! read_rollback_point; then
die "no valid rollback point at $STATE_FILE (run an update first)"
fi
log "Manual rollback to $(short_id "$ROLLBACK_ID") ($ROLLBACK_REF)"
"$DOCKER_BIN" image inspect "$ROLLBACK_ID" >/dev/null 2>&1 \
|| die "rollback image $ROLLBACK_ID is no longer present locally"
OLD_ID="$ROLLBACK_ID"
OLD_REF="$ROLLBACK_REF"
target="$(rollback_target_ref "$ROLLBACK_REF")"
if ! restore_image "$ROLLBACK_ID" "$ROLLBACK_REF"; then
health_failure_detail
exit 2
fi
if health_gate; then
log "ROLLED BACK: $target is running and /health passed."
print_health_ok
exit 0
fi
err "the rollback container is unhealthy; manual intervention is required."
health_failure_detail
exit 2
}
print_plan() {
local health_url plan_up plan_rollback
health_url="$(resolve_health_url || true)"
log "WindsurfAPI Docker update plan (dry run — nothing will change)"
log " compose file: $COMPOSE_FILE"
log " project dir: $PROJECT_DIR"
log " service: $SERVICE"
log " mode: $MODE"
log " image: $ACTIVE"
if [ -n "$CID" ]; then
log " running: $(short_id "$CID") image $(short_id "$OLD_ID") ($OLD_REF)"
else
log " running: (none — first install; automatic rollback unavailable)"
fi
if [ -n "$health_url" ]; then
log " health gate: $health_url (${HEALTH_TIMEOUT}s)"
else
log " health gate: inside $SERVICE container (${HEALTH_TIMEOUT}s)"
fi
log " rollback point: $STATE_FILE"
log " data volumes: untouched (no down, no -v, no rm)"
if [ "$MODE" = "rollback" ]; then
log " plan:"
if read_rollback_point; then
log " saved rollback: $(short_id "$ROLLBACK_ID") ($ROLLBACK_REF)"
log " 1. $DOCKER_BIN tag $ROLLBACK_ID $ROLLBACK_REF"
else
log " saved rollback: (none valid at $STATE_FILE)"
log " 1. (would fail: no valid rollback point)"
fi
log " 2. $(compose_display) up -d --no-deps --no-build --force-recreate $SERVICE"
log " 3. poll /health"
return 0
fi
log " plan:"
if [ -n "$OLD_ID" ]; then
log " 1. $DOCKER_BIN tag $OLD_ID $(rollback_target_ref "$OLD_REF") # keep rollback image"
else
log " 1. (no previous image to tag)"
fi
if [ "$MODE" = "build" ]; then
log " 2. $(compose_display) build $SERVICE"
else
log " 2. $(compose_display) pull $SERVICE"
fi
if [ "$FIRST_INSTALL" = "1" ]; then
plan_up="up -d --no-build"
else
plan_up="up -d --no-deps --no-build $SERVICE"
fi
log " 3. $(compose_display) $plan_up"
log " 4. poll /health for ${HEALTH_TIMEOUT}s"
if [ -n "$OLD_ID" ]; then
log " 5. on failure: $DOCKER_BIN tag $OLD_ID $(rollback_target_ref "$OLD_REF")"
plan_rollback="up -d --no-deps --no-build --force-recreate $SERVICE"
log " WINDSURFAPI_IMAGE=$(rollback_target_ref "$OLD_REF") $(compose_display) $plan_rollback"
log " then poll /health again; exit 1 if healthy, 2 if not"
else
log " 5. on failure: exit 2 (no rollback image)"
fi
}
run_update() {
local new_id new_cid new_ref saved_ref up_args=()
if [ -n "$OLD_ID" ]; then
saved_ref="${OLD_REF:-$(rollback_target_ref "")}"
write_state "$STATE_FILE" "$OLD_ID" "$saved_ref" \
|| log "WARNING: could not write the rollback point to $STATE_FILE"
"$DOCKER_BIN" tag "$OLD_ID" "$(rollback_target_ref "$OLD_REF")" >/dev/null 2>&1 \
|| log "WARNING: could not tag the previous image for convenience rollback"
fi
if [ "$MODE" = "build" ]; then
export_build_metadata
log "Building $ACTIVE from $ROOT_DIR..."
if ! compose build "$SERVICE"; then
err "docker compose build failed; the running container was not touched."
exit 1
fi
else
log "Pulling $ACTIVE..."
if ! compose pull "$SERVICE"; then
err "image pull failed; the running container was not touched."
log "If this image is built locally (fork or pre-release tag), retry with:"
log " bash deploy/update.sh --build"
exit 1
fi
fi
new_id="$("$DOCKER_BIN" image inspect --format '{{.Id}}' "$ACTIVE" 2>/dev/null || true)"
if [ -n "$new_id" ] && [ "$new_id" = "$OLD_ID" ]; then
log "Image unchanged ($(short_id "$new_id")); recreating to confirm health only."
elif [ -n "$new_id" ]; then
log "Image: $(short_id "$OLD_ID") -> $(short_id "$new_id")"
fi
if [ "$FIRST_INSTALL" = "1" ]; then
up_args=(-d --no-build)
log "Starting the stack for the first time..."
else
up_args=(-d --no-deps --no-build)
log "Recreating $SERVICE..."
fi
if [ "$FIRST_INSTALL" = "1" ]; then
compose up "${up_args[@]}" || rollback_and_exit "docker compose up failed" "$new_id"
else
compose up "${up_args[@]}" "$SERVICE" || rollback_and_exit "docker compose up failed" "$new_id"
fi
if ! health_gate; then
rollback_and_exit "/health did not pass within ${HEALTH_TIMEOUT}s" "$new_id"
fi
# Report what Compose actually started, not what we predicted: a fork image,
# a digest pin, or a .env override may resolve differently from ACTIVE.
new_cid="$(current_container)"
new_ref="$(inspect_field '{{.Config.Image}}' "$new_cid")"
new_id="$(inspect_field '{{.Image}}' "$new_cid")"
log "UPDATE OK: ${new_ref:-$ACTIVE} is running and /health passed."
print_health_ok
write_state "$SUCCESS_FILE" "${new_id:-unknown}" "${new_ref:-$ACTIVE}" \
"version=$(json_field "$PROBE_BODY" version)" 2>/dev/null || true
log "Data volumes were not touched. Rollback point: $STATE_FILE"
}
export_build_metadata() {
local version commit branch commit_date commit_message
if [ -z "${BUILD_VERSION:-}" ] && [ -r "$ROOT_DIR/package.json" ]; then
version="$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/package.json" | head -n1)"
[ -n "$version" ] && export BUILD_VERSION="$version"
fi
command -v git >/dev/null 2>&1 || return 0
git -C "$ROOT_DIR" rev-parse --git-dir >/dev/null 2>&1 || return 0
if [ -z "${BUILD_COMMIT:-}" ]; then
commit="$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true)"
[ -n "$commit" ] && export BUILD_COMMIT="$commit"
fi
if [ -z "${BUILD_BRANCH:-}" ]; then
branch="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
[ -n "$branch" ] && export BUILD_BRANCH="$branch"
fi
if [ -z "${BUILD_COMMIT_DATE:-}" ]; then
commit_date="$(git -C "$ROOT_DIR" log -1 --pretty=%cI 2>/dev/null || true)"
[ -n "$commit_date" ] && export BUILD_COMMIT_DATE="$commit_date"
fi
if [ -z "${BUILD_COMMIT_MESSAGE:-}" ]; then
commit_message="$(git -C "$ROOT_DIR" log -1 --pretty=%s 2>/dev/null || true)"
[ -n "$commit_message" ] && export BUILD_COMMIT_MESSAGE="$commit_message"
fi
}
main() {
parse_args "$@"
preflight
capture_current
resolve_active_image
if [ "$DRY_RUN" = "1" ]; then
print_plan
exit 0
fi
acquire_lock
trap release_lock EXIT INT TERM
if [ "$MODE" = "rollback" ]; then
manual_rollback
fi
run_update
}
main "$@"