The supervisor on evo had a bind mount of an old source copy over the image's /app sources. The deploy carried every mount over when it recreated the container, so a rebuilt image never took effect and the fix for missing bot networks stayed inactive. Mounts into /app are now dropped; data and Docker socket mounts are kept. Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq Co-authored-by: Claude <noreply@anthropic.com>
588 lines
21 KiB
Bash
Executable File
588 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 target
|
|
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
|
|
# A bind mount over the image's /app would shadow the freshly built sources, so it is not carried over.
|
|
while IFS= read -r line; do
|
|
target="${line%:ro}"
|
|
target="${target##*:}"
|
|
[[ -n "$line" && "$target" != /app && "$target" != /app/* ]] && 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 "$@"
|