diff --git a/ops/evo-deploy/README.md b/ops/evo-deploy/README.md index 82e1d6a..61907e8 100644 --- a/ops/evo-deploy/README.md +++ b/ops/evo-deploy/README.md @@ -24,7 +24,8 @@ No SSH keys or deploy secrets are stored in this repo. The workflow runs **on** - `EVO_DEPLOY_ENABLED` = `true` - Optional: `EVO_DEPLOY_DIR` (default `$HOME/Projects/evOo-local` of the runner user) - Optional: `EVO_RESTART_MODE` (`auto`, `systemd`, `tmux`, `compose`, `script`, `none`) - - Optional: `EVO_SYSTEMD_UNIT`, `EVO_TMUX_SESSION`, health URLs, timeouts + - **Evo (recommended):** pin `EVO_RESTART_MODE=systemd`, `EVO_SYSTEMD_UNIT=evoo-local.service`, and `EVO_SYSTEMD_SCOPE=user` as repo variables (or the same values in the runner host env). Auto-detect prefers systemd when that unit is active or enabled, but pinning avoids ambiguity if `infra/compose/docker-compose.yml` is present and Compose env vars are not configured for production-like deploys. + - Optional: `EVO_TMUX_SESSION`, health URLs, timeouts 3. Confirm the runner user can read/write `EVO_DEPLOY_DIR` and restart services (`systemctl --user` or tmux). Until `EVO_DEPLOY_ENABLED` is `true`, pushes to `main` do not deploy (workflow job is skipped). @@ -161,8 +162,9 @@ 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 `systemd` mode | -| `EVO_SYSTEMD_SCOPE` | `user` | `user` or `system` | +| `EVO_SYSTEMD_UNIT` | _(empty)_ | Required for explicit `systemd` mode; auto-detect 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 | | `EVO_TMUX_COMMAND` | `pnpm dev` | Command started in new tmux session | | `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` (`--skip-migrate`) | @@ -178,4 +180,12 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar |----------|---------|---------| | `EVO_WEB_MODE` | `dev` | `dev` (Vite dev server) or `preview` (production build) | -If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, it falls back to `none` and only health-checks — set `EVO_RESTART_MODE` and the matching unit/session explicitly. +When `EVO_RESTART_MODE=auto`, detection order is: + +1. **systemd** — `EVO_SYSTEMD_UNIT` is set, or `EVO_SYSTEMD_AUTO_UNIT` (default `evoo-local.service`) is active, enabled, or loaded under `systemctl --user` then `systemctl` (system scope). +2. **script** — `EVO_RESTART_SCRIPT` exists and is executable. +3. **tmux** — session `EVO_TMUX_SESSION` exists. +4. **compose** — `EVO_COMPOSE_FILE` exists under the checkout. +5. **none** — skip restart; health-check only. + +If auto mode picks the wrong path (for example Compose on Evo because the compose file exists but the stack runs under systemd), pin `EVO_RESTART_MODE=systemd` and `EVO_SYSTEMD_UNIT=evoo-local.service` in repo variables or host env. diff --git a/ops/evo-deploy/deploy.sh b/ops/evo-deploy/deploy.sh index 59bc9e5..054261c 100755 --- a/ops/evo-deploy/deploy.sh +++ b/ops/evo-deploy/deploy.sh @@ -11,6 +11,7 @@ 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}" @@ -105,6 +106,45 @@ validate_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" @@ -173,6 +213,7 @@ restart_script() { } restart_services() { + prepare_auto_restart_mode local mode mode="$(resolve_restart_mode)" case "$mode" in @@ -349,6 +390,7 @@ main() { parse_args "$@" validate_deploy_dir + prepare_auto_restart_mode local resolved_mode resolved_mode="$(resolve_restart_mode)" log "deploy dir: $EVO_DEPLOY_DIR" diff --git a/packages/testkit/src/evo-deploy.test.ts b/packages/testkit/src/evo-deploy.test.ts index 9199d7a..fb5982f 100644 --- a/packages/testkit/src/evo-deploy.test.ts +++ b/packages/testkit/src/evo-deploy.test.ts @@ -235,6 +235,58 @@ describe("evo deploy", () => { expect(f.served()).toBe("built A"); expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false); }); + + 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"); + const bin = path.join(root, "bin"); + const home = path.join(root, "home"); + const env = { + HOME: home, + PATH: searchPath(bin), + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + GIT_CONFIG_NOSYSTEM: "1", + }; + write( + path.join(bin, "systemctl"), + `#!/bin/bash +case "$*" in + "--user is-active --quiet evoo-local.service") exit 0 ;; + "--user is-enabled --quiet evoo-local.service") exit 1 ;; + "--user show -p LoadState --value evoo-local.service") echo loaded; exit 0 ;; +esac +exit 1 +`, + 0o755, + ); + write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`); + mkdirSync(checkout, { recursive: true }); + execFileSync("git", ["init", "-q", "-b", "main"], { cwd: checkout, env }); + write(path.join(checkout, "package.json"), "{}"); + execFileSync("git", ["add", "-A"], { cwd: checkout, env }); + execFileSync("git", ["commit", "-q", "-m", "init"], { cwd: checkout, env }); + execFileSync("git", ["remote", "add", "origin", checkout], { cwd: checkout, env }); + write(path.join(checkout, "infra/compose/docker-compose.yml"), "services: {}\n"); + + const result = spawnSync("/bin/bash", [deployScript, "--dry-run"], { + env: { + ...env, + EVO_DEPLOY_DIR: checkout, + EVO_RESTART_MODE: "auto", + 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("restart mode: systemd"); + expect(result.stderr).not.toContain("restart mode: compose"); + }); }); /** A checkout layout around a copy of start-stack.sh, with the real tsx and vite installs. */