#!/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 Pin ghcr.io/dwgx/windsurf-api: (v prefix optional). # --image 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 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 /docker-compose.yml). # WINDSURFAPI_PROJECT_DIR Compose project directory, used to resolve # relative bind mounts (default ). # 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 /.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 "$@"