fix(evo-deploy): validate restart config before mutating checkout (#167)

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: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
cynarAI 2026-10-05 22:39:46 +02:00 committed by GitHub
parent eb9c8847a4
commit 9062717eee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 131 additions and 11 deletions

View File

@ -111,7 +111,8 @@ bash ops/evo-deploy/deploy.sh
# Pin a SHA # Pin a SHA
EVO_DEPLOY_REF=abc1234 bash ops/evo-deploy/deploy.sh 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 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 ## 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. 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. 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`. 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_DIR` | `$HOME/Projects/evOo-local` | Git checkout root |
| `EVO_DEPLOY_REF` | `main` | Branch name or commit SHA | | `EVO_DEPLOY_REF` | `main` | Branch name or commit SHA |
| `EVO_RESTART_MODE` | `auto` | `systemd` / `tmux` / `compose` / `script` / `none` | | `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_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_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 | | `EVO_TMUX_SESSION` | `evoo` | tmux session name |

View File

@ -30,7 +30,14 @@ EVO_STATE_FILE="${EVO_STATE_FILE:-${EVO_DEPLOY_DIR}/.evo-deploy/state.json}"
DRY_RUN=false DRY_RUN=false
log() { printf '[evo-deploy] %s\n' "$*" >&2; } 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() { is_valid_git_sha() {
[[ "$1" =~ ^[0-9a-fA-F]{7,40}$ ]] [[ "$1" =~ ^[0-9a-fA-F]{7,40}$ ]]
@ -182,13 +189,11 @@ systemctl_cmd() {
} }
restart_systemd() { 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)" log "restarting systemd unit: $EVO_SYSTEMD_UNIT ($EVO_SYSTEMD_SCOPE scope)"
run systemctl_cmd restart "$EVO_SYSTEMD_UNIT" run systemctl_cmd restart "$EVO_SYSTEMD_UNIT"
} }
restart_tmux() { 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" 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 if [[ "$DRY_RUN" != true ]] && tmux has-session -t "$EVO_TMUX_SESSION" 2>/dev/null; then
tmux kill-session -t "$EVO_TMUX_SESSION" tmux kill-session -t "$EVO_TMUX_SESSION"
@ -198,7 +203,6 @@ restart_tmux() {
restart_compose() { restart_compose() {
local compose_path="$EVO_DEPLOY_DIR/$EVO_COMPOSE_FILE" 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)" log "restarting compose services ($EVO_COMPOSE_SERVICES)"
local env_args=() local env_args=()
[[ -f "$EVO_DEPLOY_DIR/$EVO_COMPOSE_ENV_FILE" ]] && env_args=(--env-file "$EVO_DEPLOY_DIR/$EVO_COMPOSE_ENV_FILE") [[ -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() { 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" log "running restart script: $EVO_RESTART_SCRIPT"
run env EVO_DEPLOY_DIR="$EVO_DEPLOY_DIR" "$EVO_RESTART_SCRIPT" run env EVO_DEPLOY_DIR="$EVO_DEPLOY_DIR" "$EVO_RESTART_SCRIPT"
} }
@ -230,6 +232,54 @@ restart_services() {
esac 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() { wait_for_url() {
local url="$1" local url="$1"
local label="$2" local label="$2"
@ -368,6 +418,7 @@ activate_web_build() {
# Restores the previous commit with its dependencies, client and web build, restarts and # 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. # health-checks it. Every step is attempted so the service comes back even if one of them fails.
rollback() { rollback() {
EVO_IN_ROLLBACK=true
local previous_sha="$1" status="rolled-back" local previous_sha="$1" status="rolled-back"
is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha" is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha"
log "ROLLBACK: resetting to $previous_sha" log "ROLLBACK: resetting to $previous_sha"
@ -393,9 +444,8 @@ main() {
parse_args "$@" parse_args "$@"
validate_deploy_dir validate_deploy_dir
prepare_auto_restart_mode validate_restart_config
local resolved_mode local resolved_mode="$EVO_RESOLVED_RESTART_MODE"
resolved_mode="$(resolve_restart_mode)"
log "deploy dir: $EVO_DEPLOY_DIR" log "deploy dir: $EVO_DEPLOY_DIR"
log "target ref: $EVO_DEPLOY_REF" log "target ref: $EVO_DEPLOY_REF"
log "restart mode: $resolved_mode" log "restart mode: $resolved_mode"
@ -403,6 +453,8 @@ main() {
local previous_sha target_sha local previous_sha target_sha
read -r previous_sha target_sha < <(git_fetch_and_checkout) 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 "$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" is_valid_git_sha "$target_sha" || die "invalid target_sha captured from checkout: $target_sha"

View File

@ -236,6 +236,71 @@ describe("evo deploy", () => {
expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false); 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", () => { it("auto mode prefers systemd over compose when the default unit is active", () => {
const root = temporaryRoot("evo-deploy-autodetect-"); const root = temporaryRoot("evo-deploy-autodetect-");
const checkout = path.join(root, "checkout"); const checkout = path.join(root, "checkout");