A supervisor that runs as a standalone container kept the image it was started with, so broker fixes never reached it. The deploy now rebuilds that image when its sources change and recreates the container with the same network, restart policy, mounts and environment, restoring the old one if the new one stays unhealthy. The broker socket moves into the data directory and is shared from containerized supervisors too. Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ Co-authored-by: Claude <noreply@anthropic.com>
585 lines
21 KiB
Bash
Executable File
585 lines
21 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# evOo-local — idempotent deploy helper for the Evo host checkout.
|
|
# Pull (or pin) a revision, install, build the web app, migrate, restart services, health-check,
|
|
# roll back on failure.
|
|
set -euo pipefail
|
|
|
|
# --- defaults (override via env; see ops/evo-deploy/README.md) ---
|
|
EVO_DEPLOY_DIR="${EVO_DEPLOY_DIR:-${HOME}/Projects/evOo-local}"
|
|
EVO_DEPLOY_REF="${EVO_DEPLOY_REF:-main}"
|
|
EVO_GIT_REMOTE="${EVO_GIT_REMOTE:-origin}"
|
|
EVO_RESTART_MODE="${EVO_RESTART_MODE:-auto}" # auto | systemd | tmux | compose | script | none
|
|
EVO_SYSTEMD_UNIT="${EVO_SYSTEMD_UNIT:-}"
|
|
EVO_SYSTEMD_SCOPE="${EVO_SYSTEMD_SCOPE:-user}" # user | system
|
|
EVO_SYSTEMD_AUTO_UNIT="${EVO_SYSTEMD_AUTO_UNIT:-evoo-local.service}"
|
|
EVO_TMUX_SESSION="${EVO_TMUX_SESSION:-evoo}"
|
|
EVO_TMUX_COMMAND="${EVO_TMUX_COMMAND:-pnpm dev}"
|
|
EVO_COMPOSE_FILE="${EVO_COMPOSE_FILE:-infra/compose/docker-compose.yml}"
|
|
EVO_COMPOSE_ENV_FILE="${EVO_COMPOSE_ENV_FILE:-.env}"
|
|
EVO_COMPOSE_SERVICES="${EVO_COMPOSE_SERVICES:-api web worker}"
|
|
EVO_RESTART_SCRIPT="${EVO_RESTART_SCRIPT:-}"
|
|
EVO_API_HEALTH_URL="${EVO_API_HEALTH_URL:-http://127.0.0.1:3100/health}"
|
|
EVO_WEB_HEALTH_URL="${EVO_WEB_HEALTH_URL:-http://127.0.0.1:5173/}"
|
|
EVO_HEALTH_TIMEOUT_SEC="${EVO_HEALTH_TIMEOUT_SEC:-120}"
|
|
EVO_MIGRATE="${EVO_MIGRATE:-true}"
|
|
EVO_INSTALL="${EVO_INSTALL:-true}"
|
|
EVO_BUILD="${EVO_BUILD:-true}"
|
|
EVO_DRY_RUN="${EVO_DRY_RUN:-false}"
|
|
EVO_STATE_FILE="${EVO_STATE_FILE:-${EVO_DEPLOY_DIR}/.evo-deploy/state.json}"
|
|
|
|
DRY_RUN=false
|
|
|
|
log() { printf '[evo-deploy] %s\n' "$*" >&2; }
|
|
die() {
|
|
log "ERROR: $*"
|
|
if [[ "${EVO_IN_ROLLBACK:-}" != true && "${EVO_CHECKOUT_DONE:-}" == true && -n "${EVO_PREVIOUS_SHA:-}" ]]; then
|
|
EVO_IN_ROLLBACK=true
|
|
rollback "$EVO_PREVIOUS_SHA"
|
|
fi
|
|
exit 1
|
|
}
|
|
|
|
is_valid_git_sha() {
|
|
[[ "$1" =~ ^[0-9a-fA-F]{7,40}$ ]]
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install] [--skip-build]
|
|
|
|
Environment: EVO_DEPLOY_DIR, EVO_DEPLOY_REF, EVO_RESTART_MODE, EVO_SYSTEMD_UNIT,
|
|
EVO_TMUX_SESSION, EVO_API_HEALTH_URL, EVO_WEB_HEALTH_URL, EVO_HEALTH_TIMEOUT_SEC, …
|
|
See ops/evo-deploy/README.md.
|
|
EOF
|
|
}
|
|
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
EVO_DRY_RUN=true
|
|
shift
|
|
;;
|
|
--ref)
|
|
[[ $# -ge 2 ]] || die "--ref requires a value"
|
|
EVO_DEPLOY_REF="$2"
|
|
shift 2
|
|
;;
|
|
--skip-migrate)
|
|
EVO_MIGRATE=false
|
|
shift
|
|
;;
|
|
--skip-install)
|
|
EVO_INSTALL=false
|
|
shift
|
|
;;
|
|
--skip-build)
|
|
EVO_BUILD=false
|
|
shift
|
|
;;
|
|
-h | --help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "unknown argument: $1"
|
|
;;
|
|
esac
|
|
done
|
|
if [[ "$EVO_DRY_RUN" == "true" ]]; then
|
|
DRY_RUN=true
|
|
fi
|
|
}
|
|
|
|
run() {
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[dry-run] $*"
|
|
return 0
|
|
fi
|
|
"$@"
|
|
}
|
|
|
|
run_shell() {
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[dry-run] $*"
|
|
return 0
|
|
fi
|
|
bash -lc "$*"
|
|
}
|
|
|
|
validate_deploy_dir() {
|
|
[[ -d "$EVO_DEPLOY_DIR" ]] || die "EVO_DEPLOY_DIR does not exist: $EVO_DEPLOY_DIR"
|
|
[[ -d "$EVO_DEPLOY_DIR/.git" ]] || die "EVO_DEPLOY_DIR is not a git checkout: $EVO_DEPLOY_DIR"
|
|
}
|
|
|
|
systemd_unit_usable() {
|
|
local unit="$1"
|
|
local scope="$2"
|
|
command -v systemctl >/dev/null 2>&1 || return 1
|
|
local -a ctl=(systemctl)
|
|
if [[ "$scope" == "user" ]]; then
|
|
ctl=(systemctl --user)
|
|
fi
|
|
if "${ctl[@]}" is-active --quiet "$unit" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
if "${ctl[@]}" is-enabled --quiet "$unit" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
local load_state
|
|
load_state="$("${ctl[@]}" show -p LoadState --value "$unit" 2>/dev/null || true)"
|
|
[[ "$load_state" == "loaded" ]]
|
|
}
|
|
|
|
# When EVO_RESTART_MODE=auto and EVO_SYSTEMD_UNIT is unset, pick a unit that is active or installed.
|
|
auto_detect_systemd() {
|
|
local unit="${EVO_SYSTEMD_UNIT:-$EVO_SYSTEMD_AUTO_UNIT}"
|
|
local scope
|
|
for scope in user system; do
|
|
if systemd_unit_usable "$unit" "$scope"; then
|
|
EVO_SYSTEMD_UNIT="$unit"
|
|
EVO_SYSTEMD_SCOPE="$scope"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Side effects must run outside $(resolve_restart_mode) — command substitution uses a subshell.
|
|
prepare_auto_restart_mode() {
|
|
[[ "$EVO_RESTART_MODE" == "auto" && -z "$EVO_SYSTEMD_UNIT" ]] || return 0
|
|
auto_detect_systemd || true
|
|
}
|
|
|
|
resolve_restart_mode() {
|
|
if [[ "$EVO_RESTART_MODE" != "auto" ]]; then
|
|
printf '%s\n' "$EVO_RESTART_MODE"
|
|
return
|
|
fi
|
|
if [[ -n "$EVO_SYSTEMD_UNIT" ]]; then
|
|
printf 'systemd\n'
|
|
return
|
|
fi
|
|
if [[ -n "$EVO_RESTART_SCRIPT" && -x "$EVO_RESTART_SCRIPT" ]]; then
|
|
printf 'script\n'
|
|
return
|
|
fi
|
|
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "$EVO_TMUX_SESSION" 2>/dev/null; then
|
|
printf 'tmux\n'
|
|
return
|
|
fi
|
|
if [[ -f "$EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE" ]]; then
|
|
printf 'compose\n'
|
|
return
|
|
fi
|
|
printf 'none\n'
|
|
}
|
|
|
|
systemctl_cmd() {
|
|
if [[ "$EVO_SYSTEMD_SCOPE" == "system" ]]; then
|
|
if [[ "$(id -u)" -eq 0 ]]; then
|
|
systemctl "$@"
|
|
else
|
|
sudo systemctl "$@"
|
|
fi
|
|
else
|
|
systemctl --user "$@"
|
|
fi
|
|
}
|
|
|
|
restart_systemd() {
|
|
log "restarting systemd unit: $EVO_SYSTEMD_UNIT ($EVO_SYSTEMD_SCOPE scope)"
|
|
run systemctl_cmd restart "$EVO_SYSTEMD_UNIT"
|
|
}
|
|
|
|
restart_tmux() {
|
|
log "restarting tmux session '$EVO_TMUX_SESSION' in $EVO_DEPLOY_DIR"
|
|
if [[ "$DRY_RUN" != true ]] && tmux has-session -t "$EVO_TMUX_SESSION" 2>/dev/null; then
|
|
tmux kill-session -t "$EVO_TMUX_SESSION"
|
|
fi
|
|
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && tmux new-session -d -s $(printf '%q' "$EVO_TMUX_SESSION") -c $(printf '%q' "$EVO_DEPLOY_DIR") $(printf '%q' "$EVO_TMUX_COMMAND")"
|
|
}
|
|
|
|
restart_compose() {
|
|
local compose_path="$EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE"
|
|
log "restarting compose services ($EVO_COMPOSE_SERVICES)"
|
|
local env_args=()
|
|
[[ -f "$EVO_DEPLOY_DIR/$EVO_COMPOSE_ENV_FILE" ]] && env_args=(--env-file "$EVO_DEPLOY_DIR/$EVO_COMPOSE_ENV_FILE")
|
|
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && docker compose ${env_args[*]} -f $(printf '%q' "$EVO_COMPOSE_FILE") up -d --wait $EVO_COMPOSE_SERVICES"
|
|
}
|
|
|
|
restart_script() {
|
|
log "running restart script: $EVO_RESTART_SCRIPT"
|
|
run env EVO_DEPLOY_DIR="$EVO_DEPLOY_DIR" "$EVO_RESTART_SCRIPT"
|
|
}
|
|
|
|
restart_services() {
|
|
prepare_auto_restart_mode
|
|
local mode
|
|
mode="$(resolve_restart_mode)"
|
|
case "$mode" in
|
|
systemd) restart_systemd ;;
|
|
tmux) restart_tmux ;;
|
|
compose) restart_compose ;;
|
|
script) restart_script ;;
|
|
none)
|
|
log "EVO_RESTART_MODE=none — skipping service restart (health check only)"
|
|
;;
|
|
*)
|
|
die "unsupported restart mode: $mode"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Fail before git fetch/checkout or install when restart settings are unusable.
|
|
# Must not run inside command substitution — it sets EVO_SYSTEMD_UNIT and related env.
|
|
validate_restart_config() {
|
|
case "$EVO_RESTART_MODE" in
|
|
auto | systemd | tmux | compose | script | none) ;;
|
|
*)
|
|
die "invalid EVO_RESTART_MODE: $EVO_RESTART_MODE (expected auto, systemd, tmux, compose, script, or none)"
|
|
;;
|
|
esac
|
|
case "$EVO_SYSTEMD_SCOPE" in
|
|
user | system) ;;
|
|
*)
|
|
die "invalid EVO_SYSTEMD_SCOPE: $EVO_SYSTEMD_SCOPE (expected user or system)"
|
|
;;
|
|
esac
|
|
|
|
prepare_auto_restart_mode
|
|
if [[ "$EVO_RESTART_MODE" == "systemd" && -z "$EVO_SYSTEMD_UNIT" ]]; then
|
|
EVO_SYSTEMD_UNIT="$EVO_SYSTEMD_AUTO_UNIT"
|
|
log "EVO_SYSTEMD_UNIT unset — using $EVO_SYSTEMD_UNIT (same default as auto mode)"
|
|
fi
|
|
|
|
local mode
|
|
mode="$(resolve_restart_mode)"
|
|
case "$mode" in
|
|
systemd)
|
|
[[ -n "$EVO_SYSTEMD_UNIT" ]] || die "EVO_RESTART_MODE=systemd requires EVO_SYSTEMD_UNIT or EVO_SYSTEMD_AUTO_UNIT"
|
|
;;
|
|
tmux)
|
|
command -v tmux >/dev/null 2>&1 || die "tmux not found; set EVO_RESTART_MODE=systemd or install tmux"
|
|
;;
|
|
compose)
|
|
[[ -f "$EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE" ]] ||
|
|
die "compose file not found: $EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE"
|
|
;;
|
|
script)
|
|
[[ -n "$EVO_RESTART_SCRIPT" ]] || die "EVO_RESTART_MODE=script requires EVO_RESTART_SCRIPT"
|
|
[[ -x "$EVO_RESTART_SCRIPT" ]] ||
|
|
die "EVO_RESTART_SCRIPT is not executable: $EVO_RESTART_SCRIPT"
|
|
;;
|
|
none) ;;
|
|
*)
|
|
die "unsupported restart mode: $mode"
|
|
;;
|
|
esac
|
|
EVO_RESOLVED_RESTART_MODE="$mode"
|
|
}
|
|
|
|
wait_for_url() {
|
|
local url="$1"
|
|
local label="$2"
|
|
local timeout="$EVO_HEALTH_TIMEOUT_SEC"
|
|
local start now last=""
|
|
start="$(date +%s)"
|
|
log "health-check $label: $url (timeout ${timeout}s)"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[dry-run] would wait for $label at $url"
|
|
return 0
|
|
fi
|
|
# Probe before checking the clock so a slow host still gets at least one attempt.
|
|
while true; do
|
|
if curl -fsS --max-time 5 "$url" >/dev/null 2>&1; then
|
|
log "$label healthy"
|
|
return 0
|
|
fi
|
|
last="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo unreachable)"
|
|
now="$(date +%s)"
|
|
if (( now - start >= timeout )); then
|
|
log "ERROR: $label health check timed out after ${timeout}s (last: ${last:-unknown})"
|
|
return 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
}
|
|
|
|
health_check() {
|
|
wait_for_url "$EVO_API_HEALTH_URL" "API" && wait_for_url "$EVO_WEB_HEALTH_URL" "web"
|
|
}
|
|
|
|
write_state() {
|
|
local previous_sha="$1" target_sha="$2" status="$3"
|
|
is_valid_git_sha "$previous_sha" || die "refusing to write state: invalid previousSha: $previous_sha"
|
|
is_valid_git_sha "$target_sha" || die "refusing to write state: invalid deployedSha: $target_sha"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[dry-run] would write state to $EVO_STATE_FILE"
|
|
return 0
|
|
fi
|
|
mkdir -p "$(dirname "$EVO_STATE_FILE")"
|
|
cat >"$EVO_STATE_FILE" <<EOF
|
|
{
|
|
"previousSha": "$previous_sha",
|
|
"deployedSha": "$target_sha",
|
|
"ref": "$EVO_DEPLOY_REF",
|
|
"status": "$status",
|
|
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
}
|
|
EOF
|
|
}
|
|
|
|
git_fetch_and_checkout() {
|
|
local previous_sha target_sha
|
|
previous_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse HEAD)"
|
|
log "previous HEAD: $previous_sha"
|
|
log "fetching $EVO_GIT_REMOTE…"
|
|
run git -C "$EVO_DEPLOY_DIR" fetch "$EVO_GIT_REMOTE" --prune >&2
|
|
# Prefer the freshly fetched remote branch: the local `main` in the deploy
|
|
# checkout never moves (we reset HEAD, not the branch), so resolving it first
|
|
# would redeploy a stale commit.
|
|
if git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
|
|
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}")"
|
|
elif git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
|
|
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_DEPLOY_REF}^{commit}")"
|
|
else
|
|
die "cannot resolve ref: $EVO_DEPLOY_REF"
|
|
fi
|
|
log "checking out $target_sha ($EVO_DEPLOY_REF)"
|
|
run git -C "$EVO_DEPLOY_DIR" reset --hard "$target_sha" >&2
|
|
printf '%s %s\n' "$previous_sha" "$target_sha"
|
|
}
|
|
|
|
run_migrate() {
|
|
[[ "$EVO_MIGRATE" == "true" ]] || { log "skipping migrate (EVO_MIGRATE=false)"; return 0; }
|
|
if [[ ! -f "$EVO_DEPLOY_DIR/package.json" ]]; then
|
|
die "package.json missing in $EVO_DEPLOY_DIR"
|
|
fi
|
|
if ! grep -q '"db:migrate"' "$EVO_DEPLOY_DIR/package.json"; then
|
|
log "no db:migrate script in package.json — skipping migrations"
|
|
return 0
|
|
fi
|
|
log "running pnpm db:generate && pnpm db:migrate"
|
|
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate && pnpm db:migrate"
|
|
}
|
|
|
|
# Rollback regenerates the client for the previous schema; applied migrations stay applied.
|
|
run_generate() {
|
|
[[ "$EVO_MIGRATE" == "true" ]] || return 0
|
|
log "running pnpm db:generate"
|
|
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate"
|
|
}
|
|
|
|
run_install() {
|
|
[[ "$EVO_INSTALL" == "true" ]] || { log "skipping install (EVO_INSTALL=false)"; return 0; }
|
|
log "running pnpm install --frozen-lockfile"
|
|
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm install --frozen-lockfile"
|
|
}
|
|
|
|
# The build goes to a staging directory, so a failing build never touches the one a running
|
|
# `vite preview` serves; activate_web_build swaps it in right before the restart.
|
|
WEB_STAGING_DIR="$EVO_DEPLOY_DIR/.evo-deploy/web-dist"
|
|
WEB_BUILD_LOG="$EVO_DEPLOY_DIR/.evo-deploy/web-build.log"
|
|
WEB_DIST_DIR="$EVO_DEPLOY_DIR/apps/web/dist"
|
|
WEB_BUILD_REPLACED=false
|
|
|
|
build_web() {
|
|
[[ "$EVO_BUILD" == "true" ]] || { log "skipping web build (EVO_BUILD=false)"; return 0; }
|
|
log "building the web app"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[dry-run] pnpm --filter @rakazo/web build --outDir $WEB_STAGING_DIR"
|
|
return 0
|
|
fi
|
|
rm -rf "$WEB_STAGING_DIR" && mkdir -p "$(dirname "$WEB_BUILD_LOG")" || return 1
|
|
if run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm --filter @rakazo/web build --outDir $(printf '%q' "$WEB_STAGING_DIR") --emptyOutDir" >"$WEB_BUILD_LOG" 2>&1 &&
|
|
[[ -f "$WEB_STAGING_DIR/index.html" ]]; then
|
|
return 0
|
|
fi
|
|
# Stack frames bury the message that names the file and line.
|
|
grep -v -E '^[[:space:]]+at ' "$WEB_BUILD_LOG" | tail -n 60 >&2 || true
|
|
log "ERROR: web build failed (full log: $WEB_BUILD_LOG)"
|
|
return 1
|
|
}
|
|
|
|
activate_web_build() {
|
|
[[ "$EVO_BUILD" == "true" ]] || return 0
|
|
local outgoing="$WEB_STAGING_DIR.outgoing"
|
|
log "activating the web build"
|
|
WEB_BUILD_REPLACED=true
|
|
run rm -rf "$outgoing" || return 1
|
|
if [[ -d "$WEB_DIST_DIR" ]]; then
|
|
run mv "$WEB_DIST_DIR" "$outgoing" || return 1
|
|
fi
|
|
run mv "$WEB_STAGING_DIR" "$WEB_DIST_DIR" || return 1
|
|
run rm -rf "$outgoing"
|
|
}
|
|
|
|
# Restores the previous commit with its dependencies, client and web build, restarts and
|
|
# health-checks it. Every step is attempted so the service comes back even if one of them fails.
|
|
rollback() {
|
|
EVO_IN_ROLLBACK=true
|
|
local previous_sha="$1" status="rolled-back"
|
|
is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha"
|
|
log "ROLLBACK: resetting to $previous_sha"
|
|
if ! run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2; then
|
|
write_state "$previous_sha" "$previous_sha" "rollback-failed"
|
|
die "deploy failed and the checkout could not be reset to $previous_sha"
|
|
fi
|
|
run_install || status="rollback-failed"
|
|
run_generate || status="rollback-failed"
|
|
# Until activation the served build is still the previous one; rebuild only once it was replaced.
|
|
if [[ "$WEB_BUILD_REPLACED" == true ]] && ! { build_web && activate_web_build; }; then
|
|
status="rollback-failed"
|
|
fi
|
|
if ! { restart_services && health_check; }; then
|
|
status="rollback-failed"
|
|
fi
|
|
write_state "$previous_sha" "$previous_sha" "$status"
|
|
[[ "$status" == "rolled-back" ]] || die "deploy failed; rollback to $previous_sha did not complete"
|
|
die "deploy failed; rolled back to $previous_sha"
|
|
}
|
|
|
|
# New computers start from rakazo/computer:local, which only `pnpm sandbox:build` refreshes.
|
|
# Rebuild it when the revision changed its sources; a failure leaves the old image and warns.
|
|
rebuild_computer_image() {
|
|
local previous_sha="$1" target_sha="$2"
|
|
git -C "$EVO_DEPLOY_DIR" diff --quiet "$previous_sha" "$target_sha" -- infra/sandboxes/computer && return 0
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
log "WARNING: the computer image changed but docker is unavailable; run pnpm sandbox:build"
|
|
return 0
|
|
fi
|
|
log "rebuilding the computer image"
|
|
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm sandbox:build" >&2 ||
|
|
log "WARNING: the computer image rebuild failed; run pnpm sandbox:build"
|
|
}
|
|
|
|
# Idle computers keep their old container until reprovisioned; drop providerRef so the next
|
|
# boot picks up the refreshed rakazo/computer image and broker shims without manual steps.
|
|
recreate_idle_computers() {
|
|
local previous_sha="$1" target_sha="$2"
|
|
git -C "$EVO_DEPLOY_DIR" diff --quiet "$previous_sha" "$target_sha" -- \
|
|
infra/sandboxes/computer \
|
|
infra/sandboxes/supervisor \
|
|
packages/adapters/src/github-broker-client.ts \
|
|
packages/adapters/src/deployment-github-token.ts && return 0
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[dry-run] would clear providerRef on stopped computers"
|
|
return 0
|
|
fi
|
|
log "recreating idle computers on next boot"
|
|
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm --filter @rakazo/db exec prisma db execute --stdin" <<'SQL' >&2 || \
|
|
log "WARNING: could not mark idle computers for recreation; restart stopped computers manually"
|
|
UPDATE "computers"
|
|
SET "providerRef" = NULL, "updatedAt" = NOW()
|
|
WHERE "state" = 'stopped'
|
|
AND "providerRef" IS NOT NULL
|
|
AND "maintenanceId" IS NULL
|
|
AND "executionRunId" IS NULL;
|
|
SQL
|
|
}
|
|
|
|
# A host that runs the sandbox supervisor as a standalone container keeps the image it was started
|
|
# with. Rebuild it when its sources changed and start a fresh container with the same name, image,
|
|
# network, restart policy, mounts and environment; the old one comes back if the new one is unhealthy.
|
|
refresh_supervisor_container() {
|
|
local previous_sha="$1" target_sha="$2"
|
|
local name="${EVO_SUPERVISOR_CONTAINER:-evoo-sandbox-supervisor}"
|
|
local health_url="${EVO_SUPERVISOR_HEALTH_URL:-http://127.0.0.1:7091/health}"
|
|
git -C "$EVO_DEPLOY_DIR" diff --quiet "$previous_sha" "$target_sha" -- \
|
|
infra/sandboxes/supervisor packages/contracts packages/core packages/logging pnpm-lock.yaml && return 0
|
|
command -v docker >/dev/null 2>&1 || return 0
|
|
docker container inspect "$name" >/dev/null 2>&1 || return 0
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[dry-run] would rebuild and recreate the $name container"
|
|
return 0
|
|
fi
|
|
|
|
local work image network restart retries line
|
|
work="$(mktemp -d)"
|
|
image="$(docker container inspect -f '{{.Config.Image}}' "$name")"
|
|
network="$(docker container inspect -f '{{.HostConfig.NetworkMode}}' "$name")"
|
|
restart="$(docker container inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$name")"
|
|
retries="$(docker container inspect -f '{{.HostConfig.RestartPolicy.MaximumRetryCount}}' "$name")"
|
|
local -a args=()
|
|
[[ -n "$network" && "$network" != default && "$network" != bridge ]] && args+=(--network "$network")
|
|
if [[ "$restart" == on-failure && "$retries" != 0 ]]; then
|
|
args+=(--restart "on-failure:$retries")
|
|
elif [[ -n "$restart" && "$restart" != no ]]; then
|
|
args+=(--restart "$restart")
|
|
fi
|
|
while IFS= read -r line; do
|
|
[[ -n "$line" ]] && args+=(-v "$line")
|
|
done < <(docker container inspect -f \
|
|
'{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}{{else}}{{.Source}}{{end}}:{{.Destination}}{{if not .RW}}:ro{{end}}{{"\n"}}{{end}}' "$name")
|
|
while IFS= read -r line; do
|
|
[[ -n "$line" ]] && args+=(-p "${line#:}")
|
|
done < <(docker container inspect -f \
|
|
'{{range $port, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostIp}}:{{.HostPort}}:{{$port}}{{"\n"}}{{end}}{{end}}' "$name")
|
|
# The image sets PATH and its Node versions itself.
|
|
(umask 077 && docker container inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" |
|
|
grep -Ev '^(PATH|NODE_VERSION|YARN_VERSION)=' >"$work/env") || true
|
|
args+=(--env-file "$work/env")
|
|
|
|
log "rebuilding the $name container"
|
|
if ! docker build -q -t "$image" -f "$EVO_DEPLOY_DIR/infra/sandboxes/supervisor/Dockerfile" "$EVO_DEPLOY_DIR" >&2; then
|
|
log "WARNING: the supervisor image rebuild failed; $name keeps running the old image"
|
|
rm -rf "$work"
|
|
return 0
|
|
fi
|
|
docker rm -f "$name-previous" >/dev/null 2>&1 || true
|
|
if ! { docker stop "$name" >/dev/null && docker rename "$name" "$name-previous"; }; then
|
|
log "WARNING: could not stop $name; it keeps running the old image"
|
|
docker start "$name" >/dev/null 2>&1 || true
|
|
rm -rf "$work"
|
|
return 0
|
|
fi
|
|
if docker run -d --name "$name" "${args[@]}" "$image" >/dev/null && wait_for_url "$health_url" "supervisor"; then
|
|
docker rm "$name-previous" >/dev/null
|
|
log "$name now runs the new image"
|
|
else
|
|
log "WARNING: the new $name container did not come up; restoring the previous one"
|
|
docker rm -f "$name" >/dev/null 2>&1 || true
|
|
{ docker rename "$name-previous" "$name" && docker start "$name" >/dev/null; } ||
|
|
log "WARNING: could not restore $name; start it with docker start $name-previous"
|
|
fi
|
|
rm -rf "$work"
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
validate_deploy_dir
|
|
|
|
validate_restart_config
|
|
local resolved_mode="$EVO_RESOLVED_RESTART_MODE"
|
|
log "deploy dir: $EVO_DEPLOY_DIR"
|
|
log "target ref: $EVO_DEPLOY_REF"
|
|
log "restart mode: $resolved_mode"
|
|
[[ "$DRY_RUN" == true ]] && log "dry-run enabled — no mutating commands"
|
|
|
|
local previous_sha target_sha
|
|
read -r previous_sha target_sha < <(git_fetch_and_checkout)
|
|
EVO_CHECKOUT_DONE=true
|
|
EVO_PREVIOUS_SHA="$previous_sha"
|
|
is_valid_git_sha "$previous_sha" || die "invalid previous_sha captured from checkout: $previous_sha"
|
|
is_valid_git_sha "$target_sha" || die "invalid target_sha captured from checkout: $target_sha"
|
|
|
|
if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then
|
|
log "already at $target_sha — continuing with install/build/migrate/restart/health"
|
|
fi
|
|
|
|
# Any failure rolls back; a failing install, build or migration stops before the restart.
|
|
if ! { run_install && build_web && run_migrate && activate_web_build && restart_services && health_check; }; then
|
|
rollback "$previous_sha"
|
|
fi
|
|
|
|
write_state "$previous_sha" "$target_sha" "success"
|
|
rebuild_computer_image "$previous_sha" "$target_sha"
|
|
refresh_supervisor_container "$previous_sha" "$target_sha"
|
|
recreate_idle_computers "$previous_sha" "$target_sha"
|
|
log "deploy succeeded: $previous_sha -> $target_sha"
|
|
}
|
|
|
|
main "$@"
|