From 075c711295464533d9e393b246039bd056c36800 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 17:24:25 +0000 Subject: [PATCH] fix(evo-deploy): validate restart config before mutating checkout Move restart-mode prerequisites (systemd unit default, tmux, compose file, script hook, EVO_SYSTEMD_SCOPE) ahead of git fetch/install/build/migrate. Explicit systemd with no EVO_SYSTEMD_UNIT falls back to EVO_SYSTEMD_AUTO_UNIT. die() after checkout triggers rollback; rollback marks in-progress to avoid loops. Co-authored-by: cynarAI --- ops/evo-deploy/README.md | 7 ++- ops/evo-deploy/deploy.sh | 70 +++++++++++++++++++++---- packages/testkit/src/evo-deploy.test.ts | 65 +++++++++++++++++++++++ 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/ops/evo-deploy/README.md b/ops/evo-deploy/README.md index 61907e8..f8cca85 100644 --- a/ops/evo-deploy/README.md +++ b/ops/evo-deploy/README.md @@ -111,7 +111,8 @@ bash ops/evo-deploy/deploy.sh # Pin a SHA EVO_DEPLOY_REF=abc1234 bash ops/evo-deploy/deploy.sh -# Force systemd restart +# Force systemd restart (unit optional — defaults to evoo-local.service) +EVO_RESTART_MODE=systemd bash ops/evo-deploy/deploy.sh EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy/deploy.sh ``` @@ -119,6 +120,8 @@ GitHub: **Actions → evo-deploy → Run workflow** (optional `ref`, `dry_run`, ## Deploy and rollback behavior +Restart mode and its prerequisites (systemd unit name, tmux, compose file, restart script, valid `EVO_SYSTEMD_SCOPE`) are validated **before** `git fetch`, install, build, migrate, or web activation. A bad configuration exits immediately without changing the checkout or database. + 1. Before `git fetch`, the script records `previousSha` (current `HEAD`), then resets the checkout to the target. 2. `pnpm install --frozen-lockfile`, then the web build into `.evo-deploy/web-dist`. Its output goes to `.evo-deploy/web-build.log` and is printed only when the build fails. 3. `pnpm db:generate && pnpm db:migrate`. @@ -162,7 +165,7 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar | `EVO_DEPLOY_DIR` | `$HOME/Projects/evOo-local` | Git checkout root | | `EVO_DEPLOY_REF` | `main` | Branch name or commit SHA | | `EVO_RESTART_MODE` | `auto` | `systemd` / `tmux` / `compose` / `script` / `none` | -| `EVO_SYSTEMD_UNIT` | _(empty)_ | Required for explicit `systemd` mode; auto-detect sets this when a unit is found | +| `EVO_SYSTEMD_UNIT` | _(empty)_ | Systemd unit to restart; when `EVO_RESTART_MODE=systemd` and unset, defaults to `EVO_SYSTEMD_AUTO_UNIT` (same as auto mode). Auto mode sets this when a unit is found | | `EVO_SYSTEMD_SCOPE` | `user` | `user` or `system` (auto-detect picks the scope where the unit is active/enabled) | | `EVO_SYSTEMD_AUTO_UNIT` | `evoo-local.service` | Unit name probed when `EVO_RESTART_MODE=auto` and `EVO_SYSTEMD_UNIT` is unset | | `EVO_TMUX_SESSION` | `evoo` | tmux session name | diff --git a/ops/evo-deploy/deploy.sh b/ops/evo-deploy/deploy.sh index 3abcf17..ac6bb05 100755 --- a/ops/evo-deploy/deploy.sh +++ b/ops/evo-deploy/deploy.sh @@ -30,7 +30,14 @@ 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: $*"; exit 1; } +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}$ ]] @@ -182,13 +189,11 @@ systemctl_cmd() { } restart_systemd() { - [[ -n "$EVO_SYSTEMD_UNIT" ]] || die "EVO_RESTART_MODE=systemd requires EVO_SYSTEMD_UNIT" log "restarting systemd unit: $EVO_SYSTEMD_UNIT ($EVO_SYSTEMD_SCOPE scope)" run systemctl_cmd restart "$EVO_SYSTEMD_UNIT" } restart_tmux() { - command -v tmux >/dev/null 2>&1 || die "tmux not found; set EVO_RESTART_MODE=systemd or install 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" @@ -198,7 +203,6 @@ restart_tmux() { restart_compose() { local compose_path="$EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE" - [[ -f "$compose_path" ]] || die "compose file not found: $compose_path" 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") @@ -206,8 +210,6 @@ restart_compose() { } restart_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" log "running restart script: $EVO_RESTART_SCRIPT" run env EVO_DEPLOY_DIR="$EVO_DEPLOY_DIR" "$EVO_RESTART_SCRIPT" } @@ -230,6 +232,54 @@ restart_services() { 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" @@ -368,6 +418,7 @@ activate_web_build() { # 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" @@ -393,9 +444,8 @@ main() { parse_args "$@" validate_deploy_dir - prepare_auto_restart_mode - local resolved_mode - resolved_mode="$(resolve_restart_mode)" + 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" @@ -403,6 +453,8 @@ main() { 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" diff --git a/packages/testkit/src/evo-deploy.test.ts b/packages/testkit/src/evo-deploy.test.ts index fb5982f..c9b9638 100644 --- a/packages/testkit/src/evo-deploy.test.ts +++ b/packages/testkit/src/evo-deploy.test.ts @@ -236,6 +236,71 @@ describe("evo deploy", () => { expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false); }); + it("rejects script mode without EVO_RESTART_SCRIPT before mutating the checkout", () => { + const f = deployFixture({}); + const root = path.dirname(f.checkout); + const bin = path.join(root, "bin"); + const home = path.join(root, "home"); + const result = spawnSync("/bin/bash", [deployScript], { + env: { + HOME: home, + PATH: searchPath(bin), + GIT_CONFIG_NOSYSTEM: "1", + EVO_DEPLOY_DIR: f.checkout, + EVO_DEPLOY_REF: f.b, + EVO_RESTART_MODE: "script", + EVO_HEALTH_TIMEOUT_SEC: "1", + }, + encoding: "utf8", + timeout: 30_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("EVO_RESTART_MODE=script requires EVO_RESTART_SCRIPT"); + expect(f.commands()).toEqual([]); + expect(f.head()).toBe(f.a); + expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false); + }); + + it("systemd mode without EVO_SYSTEMD_UNIT uses the auto unit name before deploy", () => { + const f = deployFixture({}); + const root = path.dirname(f.checkout); + const bin = path.join(root, "bin"); + const home = path.join(root, "home"); + write( + path.join(bin, "systemctl"), + `#!/bin/bash +case "$*" in + "--user restart evoo-local.service") exit 0 ;; +esac +exit 1 +`, + 0o755, + ); + write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`); + const result = spawnSync("/bin/bash", [deployScript], { + env: { + HOME: home, + PATH: searchPath(bin), + COMMAND_LOG: path.join(root, "commands.log"), + GIT_CONFIG_NOSYSTEM: "1", + EVO_DEPLOY_DIR: f.checkout, + EVO_DEPLOY_REF: f.b, + EVO_RESTART_MODE: "systemd", + EVO_SYSTEMD_SCOPE: "user", + EVO_HEALTH_TIMEOUT_SEC: "1", + }, + encoding: "utf8", + timeout: 30_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("EVO_SYSTEMD_UNIT unset — using evoo-local.service"); + expect(result.stderr).toContain("restart mode: systemd"); + }); + it("auto mode prefers systemd over compose when the default unit is active", () => { const root = temporaryRoot("evo-deploy-autodetect-"); const checkout = path.join(root, "checkout");