-
Notifications
You must be signed in to change notification settings - Fork 6
feat(deploy): support systemd --user process manager for nextjs-ssr #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,6 +85,67 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> { | |
| Ok(removed) | ||
| } | ||
|
|
||
| /// Build the shell snippet that (re)starts the app after the standalone bundle | ||
| /// is on the server. | ||
| /// | ||
| /// Two supervisors are supported, selected by `process_manager`: | ||
| /// - `None` / anything but `"systemd"` → **pm2** (default): delete + start | ||
| /// fresh, preferring an operator-managed `ecosystem.config.cjs`/`.js`, else | ||
| /// `pm2 start node -- server.js` with `PORT`/`HOSTNAME` inline. | ||
| /// - `"systemd"` → a **git-owned `systemctl --user` service** named | ||
| /// `<pm2_app>.service` (created out-of-band by the pm2-to-systemd migration). | ||
| /// Restarted with no sudo — the deploy SSHes as the git app-owner, and | ||
| /// `systemctl --user` (with `XDG_RUNTIME_DIR` set) drives that user's own | ||
| /// manager. Fails loudly if the unit does not exist. | ||
| /// | ||
| /// The returned snippet references the shell var `$PM2_APP` (set earlier in the | ||
| /// deploy script). `port` is only used by the pm2 fallback path. | ||
| fn restart_stanza(process_manager: Option<&str>, port: u16) -> String { | ||
| let use_systemd = process_manager | ||
| .map(|m| m.eq_ignore_ascii_case("systemd")) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Reject unknown process-manager values instead of falling back to PM2. Any configured value other than an exact case-insensitive |
||
| .unwrap_or(false); | ||
|
|
||
| if use_systemd { | ||
| r#" echo "Restarting $PM2_APP via systemd --user…" | ||
| export XDG_RUNTIME_DIR="/run/user/$(id -u)" | ||
| UNIT="$PM2_APP.service" | ||
| if ! systemctl --user cat "$UNIT" >/dev/null 2>&1; then | ||
| echo "Error: systemd --user unit $UNIT not found on the server." | ||
| echo "Create it first with the pm2-to-systemd migration, then re-deploy." | ||
| exit 1 | ||
| fi | ||
| systemctl --user restart "$UNIT" | ||
| sleep 3 | ||
| if ! systemctl --user is-active "$UNIT" >/dev/null 2>&1; then | ||
| echo "Error: $UNIT failed to become active after restart." | ||
| systemctl --user status "$UNIT" --no-pager 2>&1 | head -20 || true | ||
| exit 1 | ||
| fi | ||
| echo "Done." | ||
| "# | ||
| .to_string() | ||
| } else { | ||
| format!( | ||
| r#" echo "Starting $PM2_APP with pm2..." | ||
| if pm2 describe "$PM2_APP" > /dev/null 2>&1; then | ||
| pm2 delete "$PM2_APP" | ||
| fi | ||
|
|
||
| if [ -f ecosystem.config.cjs ]; then | ||
| pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production | ||
| elif [ -f ecosystem.config.js ]; then | ||
| pm2 start ecosystem.config.js --only "$PM2_APP" --env production | ||
| else | ||
| NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js | ||
| fi | ||
|
|
||
| pm2 save | ||
| echo "Done." | ||
| "# | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /// Deploys a Next.js SSR app using standalone output mode. | ||
| /// | ||
| /// Requires `output: 'standalone'` in next.config.js. This produces a | ||
|
|
@@ -99,7 +160,8 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> { | |
| /// 4. rsync .next/standalone/ → server:path/ (server + bundled deps) | ||
| /// 5. rsync .next/static/ → server:path/.next/static/ (static chunks) | ||
| /// 6. rsync public/ → server:path/public/ (public assets) | ||
| /// 7. SSH: pm2 delete + start fresh (prefer server `ecosystem.config.cjs` or `.js` if present) | ||
| /// 7. SSH: restart the app — pm2 (default) or a git-owned systemd --user | ||
| /// service when `process_manager = "systemd"` (see `restart_stanza`) | ||
| /// 8. PATCH deployment record as Done | ||
| pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Result<CommandResult> { | ||
| let source = config.project.source.as_deref().unwrap_or("."); | ||
|
|
@@ -530,6 +592,9 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu | |
| ) | ||
| }; | ||
|
|
||
| // Runtime supervisor: pm2 (default) or a git-owned systemd --user service. | ||
| let restart_stanza = restart_stanza(config.project.process_manager.as_deref(), port); | ||
|
|
||
| let deploy_script = format!( | ||
| r#"set -e | ||
| APP_PATH="{remote_path}" | ||
|
|
@@ -662,26 +727,11 @@ EOF | |
| ln -sfn ../../server.js .next/standalone/server.js | ||
| fi | ||
|
|
||
| echo "Starting $PM2_APP with pm2..." | ||
| if pm2 describe "$PM2_APP" > /dev/null 2>&1; then | ||
| pm2 delete "$PM2_APP" | ||
| fi | ||
|
|
||
| if [ -f ecosystem.config.cjs ]; then | ||
| pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production | ||
| elif [ -f ecosystem.config.js ]; then | ||
| pm2 start ecosystem.config.js --only "$PM2_APP" --env production | ||
| else | ||
| NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js | ||
| fi | ||
|
|
||
| pm2 save | ||
| echo "Done." | ||
| "#, | ||
| {restart} "#, | ||
| remote_path = remote_path, | ||
| pm2_app = pm2_app, | ||
| runtime_subdir = runtime_subdir_for_shell, | ||
| port = port, | ||
| restart = restart_stanza, | ||
| ecosystem_config = ecosystem_config_content, | ||
| ); | ||
|
|
||
|
|
@@ -828,7 +878,11 @@ async fn mark_failed( | |
|
|
||
| #[cfg(all(test, unix))] | ||
| mod tests { | ||
| use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir}; | ||
| use { | ||
| super::{prune_dangling_symlinks, restart_stanza}, | ||
| std::os::unix::fs::symlink, | ||
| tempfile::tempdir, | ||
| }; | ||
|
|
||
| /// Mirrors the real failure: a pnpm compat link points at a version dir that | ||
| /// was never traced into the bundle, so it dangles and must be pruned, while | ||
|
|
@@ -915,4 +969,27 @@ mod tests { | |
| "the dangling root symlink is gone" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn restart_stanza_defaults_to_pm2() { | ||
| for pm in [None, Some("pm2"), Some("PM2"), Some("other")] { | ||
| let s = restart_stanza(pm, 3025); | ||
| assert!(s.contains("pm2 start"), "pm={pm:?} should use pm2"); | ||
| assert!(s.contains("PORT=3025"), "pm={pm:?} injects the port"); | ||
| assert!(!s.contains("systemctl"), "pm={pm:?} must not touch systemd"); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn restart_stanza_systemd_uses_user_unit_no_sudo() { | ||
| let s = restart_stanza(Some("systemd"), 3025); | ||
| assert!(s.contains("systemctl --user restart \"$UNIT\"")); | ||
| assert!(s.contains("UNIT=\"$PM2_APP.service\"")); | ||
| assert!(s.contains("XDG_RUNTIME_DIR=\"/run/user/$(id -u)\"")); | ||
| // No sudo (git drives its own user manager) and no pm2 fallback. | ||
| assert!(!s.contains("sudo"), "must not require sudo"); | ||
| assert!(!s.contains("pm2 "), "systemd path must not call pm2"); | ||
| // Case-insensitive selector. | ||
| assert_eq!(s, restart_stanza(Some("SystemD"), 3025)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Ship or replace the referenced migration command.
pm2-to-systemd.shis not tracked anywhere in this PR/repository (the only tracked shell scripts are the installers), so a user following these prerequisites cannot create the required unit, and the runtime error also points to a migration that is unavailable. Please add the generator, link to a versioned accessible location, or document the complete unit/env creation and cutover commands here.