@smbcloud / cli / commits / 9fa6785

Add systemd mode, health check and rollback to Rust deploy (#67)

* Add systemd mode, health check and rollback to Rust deploy kind = "rust" used to kill the old process, start the new one with nohup and call it deployed if pidof found it a second later. That races a supervisor and cuts long streams after two seconds. process_manager = "systemd" now restarts <binary_name>.service with systemctl --user (no sudo). Unset or "nohup" keeps the old start. Any other value is an error. For both modes the binary is uploaded as <binary>.new, the live one is copied to <binary>.previous, and .new is moved into place. When port and the new health_path are both set, the script polls the health URL for up to 30 seconds; otherwise it checks is-active (systemd) or pidof (nohup). If the check fails and .previous exists, the script restores it, restarts, prints the last log lines and exits non-zero. Adds a smbcloud-deploy-rust skill covering config, remote layout and the user unit setup. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Reject malformed percent-escapes in health_path The health_path allowlist accepted `%` but never checked what followed it, so `/foo%ZZ` passed validation and only failed at deploy time as a health-check failure and rollback. Require every `%` to be followed by two hex digits. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: siGit Code v1.6.0 <noreply@sigit.si> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: siGit Code v1.6.0 <noreply@sigit.si>

keypair34 committed Oct 6, 2026 at 10:11 UTC 9fa67857b83b93e78e57765501ef24da689af4ad
5 files changed +491 -32
.agents/skills/smbcloud-deploy-rust/SKILL.md new
+98
@@ -0,0 +1,98 @@
1 +---
2 +name: smbcloud-deploy-rust
3 +description: Use when deploying or debugging Rust services on smbCloud with `kind = "rust"`, which cross-compiles a Linux binary locally, uploads it with rsync, and restarts it over SSH (nohup by default, or a git-owned `systemctl --user` service when `process_manager = "systemd"`), with a health check and automatic rollback to the previous binary.
4 +---
5 +
6 +# smbCloud Deploy Rust
7 +
8 +Use this skill when work touches `kind = "rust"` deploy targets.
9 +
10 +## What `smb deploy` does
11 +
12 +1. Resolves `process_manager` and `health_path` (a bad value fails before the build).
13 +2. Builds `--release --bin <binary_name>` for `rust_target` (cargo, `cargo-zigbuild` or `cross`).
14 +3. rsyncs the binary to `<path>/<binary>.new`.
15 +4. Runs a remote script that copies the live binary to `<binary>.previous`, moves `.new` into place, restarts the service, and checks it.
16 +5. If the check fails and `.previous` exists, restores it, restarts, prints the last 20 log lines, and exits non-zero. The deployment is marked `Failed`.
17 +
18 +## Config
19 +
20 +```toml
21 +[project]
22 +kind = "rust"
23 +path = "apps/rest-api/my-service" # remote app directory
24 +binary_name = "my-service" # optional; defaults to the Cargo package name
25 +rust_target = "x86_64-unknown-linux-gnu" # optional
26 +process_manager = "systemd" # optional; "nohup" (default) or "systemd"
27 +port = 8080 # optional; used by the health check
28 +health_path = "/health" # optional; must start with "/"
29 +```
30 +
31 +- `process_manager`: unset, `""` and `"nohup"` keep the kill-and-nohup start. `"systemd"` restarts `<binary_name>.service`. Any other value is an error, so a typo cannot start a second process next to a supervised one.
32 +- `health_path`: with `port` set, the script polls `http://127.0.0.1:<port><health_path>` with `curl -fsS` once a second for up to 30 seconds. Without both, the check is `systemctl --user is-active` after a short sleep (systemd) or `pidof` (nohup). Only letters, digits and `/ - _ . ~ %` are accepted.
33 +
34 +## Remote layout
35 +
36 +```
37 +<path>/<binary> live binary
38 +<path>/<binary>.new upload staging, moved into place by the deploy
39 +<path>/<binary>.previous copy of the binary that was live before this deploy
40 +<path>/<binary>.log nohup mode only
41 +```
42 +
43 +## Process manager: systemd --user
44 +
45 +The deploy SSHes as the app user and runs `systemctl --user restart <binary_name>.service` with `XDG_RUNTIME_DIR=/run/user/$(id -u)`. No sudo. The unit is created out of band; a missing unit fails the deploy before the binary is swapped.
46 +
47 +The graceful stop time is the unit's `TimeoutStopSec`. Set it above the longest stream the service holds.
48 +
49 +Prerequisites, once per app:
50 +
51 +1. Enable linger for the app user so the unit runs without a login and starts at boot: `sudo loginctl enable-linger <user>`.
52 +2. Create the env file and the unit as that user:
53 +
54 + ```sh
55 + export XDG_RUNTIME_DIR="/run/user/$(id -u)"
56 + APP=my-service # == binary_name == the unit name
57 + APP_PATH=~/apps/rest-api/my-service # == `path` in .smb/config.toml
58 + mkdir -p ~/.config/systemd/user
59 +
60 + install -m 600 /dev/null ~/.config/systemd/user/$APP.env
61 + # add the service's environment variables, one KEY=value per line
62 +
63 + cat > ~/.config/systemd/user/$APP.service <<EOF2
64 + [Unit]
65 + Description=$APP
66 + After=network-online.target
67 +
68 + [Service]
69 + Type=simple
70 + WorkingDirectory=$APP_PATH
71 + EnvironmentFile=%h/.config/systemd/user/$APP.env
72 + ExecStart=$APP_PATH/$APP
73 + Restart=on-failure
74 + RestartSec=2
75 + TimeoutStopSec=60
76 +
77 + [Install]
78 + WantedBy=default.target
79 + EOF2
80 +
81 + systemctl --user daemon-reload
82 + ```
83 +
84 +3. Set `process_manager = "systemd"` in `.smb/config.toml`.
85 +
86 +## Cutting over from nohup or a system unit
87 +
88 +From a nohup process: stop it (`kill $(pidof <binary>)`), then `systemctl --user enable --now <binary>.service` and confirm `systemctl --user is-active`. Never run both; they fight over the port.
89 +
90 +From a system unit (`/etc/systemd/system`): this mode does not use sudo, so move the service to a user unit first: stop and disable the system unit, create the user unit as above, enable it, then switch `process_manager`. Until then, keep deploying that service manually.
91 +
92 +## Troubleshooting
93 +
94 +- `unit ... not found`: the user unit does not exist or `daemon-reload` was not run.
95 +- `Failed to connect to bus`: linger is not enabled for the app user.
96 +- Health check times out: check `journalctl --user -u <binary>.service -n 50`, and that `port` matches what the service binds.
97 +- Rolled back: the previous binary is serving again; the failed build is gone from the server, fix and redeploy.
98 +- No `.previous` on the first deploy, so a failed first deploy cannot roll back.
crates/cli/src/cloud-deploy/process_deploy_rust.rs
+378 -27
@@ -38,6 +38,13 @@ const DEFAULT_RUST_TARGET: &str = "x86_64-unknown-linux-gnu";
38 /// - `source` — local crate directory (defaults to current directory)
39 /// - `binary_name` — binary filename to upload; falls back to Cargo package name
40 /// - `rust_target` — local cross-compilation target triple; defaults to `x86_64-unknown-linux-gnu`
41 +/// - `process_manager` — `"nohup"` (default) or `"systemd"`; systemd restarts
42 +/// `<binary_name>.service` with `systemctl --user` (see `resolve_supervisor`)
43 +/// - `port`, `health_path` — when both are set, the deploy polls
44 +/// `http://127.0.0.1:<port><health_path>` after the restart
45 +///
46 +/// The binary is uploaded as `<binary>.new`, the live one is kept as
47 +/// `<binary>.previous`, and a failed check restores it.
48 pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<CommandResult> {
49 let deploy_start = std::time::Instant::now();
50
@@ -56,6 +63,9 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
63 ))));
64 }
65
66 + let supervisor = resolve_supervisor(config.project.process_manager.as_deref())?;
67 + let health_url =
68 + resolve_health_url(config.project.port, config.project.health_path.as_deref())?;
69 let binary_name = resolve_binary_name(&config, source_dir)?;
70 let rust_target = config
71 .project
@@ -160,7 +170,10 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
170 } else {
171 format!("{}/", remote_path)
172 };
163 - let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
173 + let destination = format!(
174 + "git@{}:{}{}.new",
175 + rsync_host, remote_with_slash, binary_name
176 + );
177 let binary_path_str = binary_path.to_string_lossy().into_owned();
178
179 let binary_size = fs::metadata(&binary_path).map(|m| m.len()).unwrap_or(0);
@@ -229,7 +242,8 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
242 ),
243 );
244
232 - let deploy_script = build_remote_start_script(remote_path, &binary_name);
245 + let deploy_script =
246 + build_remote_start_script(remote_path, &binary_name, supervisor, health_url.as_deref());
247 let ssh_output = run_remote_script(
248 &identity_file_str,
249 &known_hosts_file,
@@ -247,6 +261,12 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
261 fail_message("Launch failed")
262 );
263 print_output_details(&ssh_output);
264 + if let Some(line) = String::from_utf8_lossy(&ssh_output.stdout)
265 + .lines()
266 + .find(|line| line.starts_with("Rolled back"))
267 + {
268 + eprintln!("{}", line);
269 + }
270 mark_failed(
271 &deploy_ref,
272 &created_deployment,
@@ -474,9 +494,71 @@ echo "Prepared $APP_PATH"
494 )
495 }
496
477 -fn build_remote_start_script(remote_path: &str, binary_name: &str) -> String {
478 - format!(
497 +/// How the service is stopped and started on the server.
498 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499 +enum Supervisor {
500 + /// Kill the old process, start the new one with `nohup`.
501 + Nohup,
502 + /// `systemctl --user restart <binary_name>.service`.
503 + Systemd,
504 +}
505 +
506 +/// Maps `process_manager` to a [`Supervisor`] (matched case-insensitively).
507 +/// `None`, `""` and `"nohup"` keep the original behaviour. Any other value is a
508 +/// hard error: a typo like `system` must not start a nohup process next to a
509 +/// systemd-owned one that already holds the port.
510 +fn resolve_supervisor(process_manager: Option<&str>) -> Result<Supervisor> {
511 + match process_manager.map(str::trim) {
512 + None | Some("") => Ok(Supervisor::Nohup),
513 + Some(m) if m.eq_ignore_ascii_case("nohup") => Ok(Supervisor::Nohup),
514 + Some(m) if m.eq_ignore_ascii_case("systemd") => Ok(Supervisor::Systemd),
515 + Some(other) => Err(anyhow!(fail_message(&format!(
516 + "Unknown process_manager '{other}' in .smb/config.toml. \
517 + Expected \"nohup\" (default) or \"systemd\"."
518 + )))),
519 + }
520 +}
521 +
522 +/// Builds the URL the remote script polls after the restart. `None` unless both
523 +/// `port` and `health_path` are set. The path ends up in a shell script, so it
524 +/// is limited to URL-path characters, and every `%` must start a valid
525 +/// percent-escape.
526 +fn resolve_health_url(port: Option<u16>, health_path: Option<&str>) -> Result<Option<String>> {
527 + let Some(path) = health_path.map(str::trim).filter(|path| !path.is_empty()) else {
528 + return Ok(None);
529 + };
530 + let bytes = path.as_bytes();
531 + let valid = path.starts_with('/')
532 + && path
533 + .chars()
534 + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.' | '~' | '%'))
535 + && bytes.iter().enumerate().all(|(i, &b)| {
536 + b != b'%'
537 + || bytes
538 + .get(i + 1..i + 3)
539 + .is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit))
540 + });
541 + if !valid {
542 + return Err(anyhow!(fail_message(&format!(
543 + "Invalid health_path '{path}' in .smb/config.toml. \
544 + It must start with '/' and contain only letters, digits and / - _ . ~ %; \
545 + each % must be followed by two hex digits."
546 + ))));
547 + }
548 + Ok(port.map(|port| format!("http://127.0.0.1:{port}{path}")))
549 +}
550 +
551 +/// Remote script: swap the uploaded `<binary>.new` into place (keeping the live
552 +/// one as `<binary>.previous`), restart, verify, and roll back on failure.
553 +fn build_remote_start_script(
554 + remote_path: &str,
555 + binary_name: &str,
556 + supervisor: Supervisor,
557 + health_url: Option<&str>,
558 +) -> String {
559 + let mut script = format!(
560 r#"set -e
561 +exec 2>&1
562 APP_PATH={remote_path}
563 PROCESS_NAME={binary_name}
564
@@ -492,41 +574,166 @@ fi
574
575 cd "$APP_PATH"
576
495 -if [ ! -f "$PROCESS_NAME" ]; then
496 - echo "Error: $PROCESS_NAME does not exist in $APP_PATH."
577 +if [ ! -f "$PROCESS_NAME.new" ]; then
578 + echo "Error: $PROCESS_NAME.new does not exist in $APP_PATH."
579 exit 1
580 fi
581
500 -chmod +x "$PROCESS_NAME"
501 -
502 -PID=$(pidof "$PROCESS_NAME" 2>/dev/null || true)
582 +"#,
583 + remote_path = shell_single_quote(remote_path),
584 + binary_name = shell_single_quote(binary_name),
585 + );
586 + script.push_str(&restart_section(supervisor));
587 + script.push_str(&verify_section(supervisor, health_url));
588 + script.push_str(swap_section());
589 + script.push_str(
590 + r#"if restart_service && check_service; then
591 + started
592 + echo "Done."
593 + exit 0
594 +fi
595
504 -if [ -n "$PID" ]; then
505 - echo "Stopping $PROCESS_NAME ($PID)..."
506 - kill "$PID" 2>/dev/null || true
507 - sleep 2
508 - if kill -0 "$PID" 2>/dev/null; then
509 - echo "Force-killing $PROCESS_NAME ($PID)..."
510 - kill -9 "$PID" 2>/dev/null || true
596 +echo "Error: $PROCESS_NAME failed its check after the restart."
597 +show_logs
598 +if [ -f "$PROCESS_NAME.previous" ]; then
599 + echo "Rolling back to the previous binary..."
600 + cp -p "$PROCESS_NAME.previous" "$PROCESS_NAME.rollback"
601 + mv -f "$PROCESS_NAME.rollback" "$PROCESS_NAME"
602 + if restart_service && check_service; then
603 + echo "Rolled back to the previous $PROCESS_NAME, which is serving again."
604 + else
605 + echo "Rolled back to the previous $PROCESS_NAME, but it failed its check too."
606 fi
607 +else
608 + echo "No previous binary to roll back to."
609 +fi
610 +exit 1
611 +"#,
612 + );
613 + script
614 +}
615 +
616 +/// Keeps the live binary as `.previous` and moves `.new` into place.
617 +fn swap_section() -> &'static str {
618 + r#"if [ -f "$PROCESS_NAME" ]; then
619 + cp -p "$PROCESS_NAME" "$PROCESS_NAME.previous"
620 fi
621 +chmod +x "$PROCESS_NAME.new"
622 +mv -f "$PROCESS_NAME.new" "$PROCESS_NAME"
623
514 -echo "Starting $PROCESS_NAME..."
515 -nohup "./$PROCESS_NAME" >> "$APP_PATH/$PROCESS_NAME.log" 2>&1 &
516 -sleep 1
624 +"#
625 +}
626 +
627 +/// Defines `restart_service`. The systemd variant also fails early, before the
628 +/// swap, when the unit does not exist.
629 +fn restart_section(supervisor: Supervisor) -> String {
630 + match supervisor {
631 + Supervisor::Nohup => r#"restart_service() {
632 + PID=$(pidof "$PROCESS_NAME" 2>/dev/null || true)
633 + if [ -n "$PID" ]; then
634 + echo "Stopping $PROCESS_NAME ($PID)..."
635 + kill "$PID" 2>/dev/null || true
636 + sleep 2
637 + if kill -0 "$PID" 2>/dev/null; then
638 + echo "Force-killing $PROCESS_NAME ($PID)..."
639 + kill -9 "$PID" 2>/dev/null || true
640 + fi
641 + fi
642 + echo "Starting $PROCESS_NAME..."
643 + nohup "./$PROCESS_NAME" >> "$APP_PATH/$PROCESS_NAME.log" 2>&1 &
644 +}
645
518 -NEW_PID=$(pidof "$PROCESS_NAME" 2>/dev/null || true)
519 -if [ -z "$NEW_PID" ]; then
520 - echo "Error: failed to start $PROCESS_NAME."
646 +"#
647 + .to_owned(),
648 + Supervisor::Systemd => r#"export XDG_RUNTIME_DIR="/run/user/$(id -u)"
649 +UNIT="$PROCESS_NAME.service"
650 +if ! systemctl --user cat "$UNIT" >/dev/null 2>&1; then
651 + echo "Error: systemd --user unit $UNIT not found on the server."
652 + echo "Create ~/.config/systemd/user/$UNIT and enable it (see the"
653 + echo "smbcloud-deploy-rust guide, 'Process manager: systemd --user'),"
654 + echo "or unset process_manager to use the default start. Then re-deploy."
655 exit 1
656 fi
657
524 -echo "Started $PROCESS_NAME as $NEW_PID"
525 -echo "Done."
658 +restart_service() {
659 + echo "Restarting $UNIT via systemd --user..."
660 + systemctl --user restart "$UNIT"
661 +}
662 +
663 +"#
664 + .to_owned(),
665 + }
666 +}
667 +
668 +/// Defines `check_service`, `started` and `show_logs`. The check polls the
669 +/// health URL for up to 30 seconds when there is one, else looks for a live
670 +/// process (`is-active` under systemd, `pidof` under nohup).
671 +fn verify_section(supervisor: Supervisor, health_url: Option<&str>) -> String {
672 + let mut section = String::new();
673 + match health_url {
674 + Some(url) => {
675 + section.push_str(&format!("HEALTH_URL={}\n\n", shell_single_quote(url)));
676 + section.push_str(
677 + r#"check_service() {
678 + echo "Waiting for $HEALTH_URL..."
679 + i=0
680 + while [ "$i" -lt 30 ]; do
681 + if curl -fsS -o /dev/null --max-time 2 "$HEALTH_URL"; then
682 + return 0
683 + fi
684 + i=$((i + 1))
685 + sleep 1
686 + done
687 + return 1
688 +}
689 +
690 "#,
527 - remote_path = shell_single_quote(remote_path),
528 - binary_name = shell_single_quote(binary_name),
529 - )
691 + );
692 + }
693 + None => section.push_str(match supervisor {
694 + Supervisor::Systemd => {
695 + r#"check_service() {
696 + sleep 3
697 + systemctl --user is-active "$UNIT" >/dev/null 2>&1
698 +}
699 +
700 +"#
701 + }
702 + Supervisor::Nohup => {
703 + r#"check_service() {
704 + sleep 1
705 + [ -n "$(pidof "$PROCESS_NAME" 2>/dev/null || true)" ]
706 +}
707 +
708 +"#
709 + }
710 + }),
711 + }
712 + section.push_str(match supervisor {
713 + Supervisor::Systemd => {
714 + r#"started() {
715 + echo "Started $PROCESS_NAME via $UNIT"
716 +}
717 +
718 +show_logs() {
719 + journalctl --user -u "$UNIT" -n 20 --no-pager 2>&1 || true
720 +}
721 +
722 +"#
723 + }
724 + Supervisor::Nohup => {
725 + r#"started() {
726 + echo "Started $PROCESS_NAME as $(pidof "$PROCESS_NAME" 2>/dev/null || true)"
727 +}
728 +
729 +show_logs() {
730 + tail -n 20 "$APP_PATH/$PROCESS_NAME.log" 2>&1 || true
731 +}
732 +
733 +"#
734 + }
735 + });
736 + section
737 }
738
739 fn build_ssh_command(identity_file: &str, known_hosts_file: &NamedTempFile) -> String {
@@ -639,3 +846,147 @@ async fn mark_failed(
846 .await;
847 }
848 }
849 +
850 +#[cfg(test)]
851 +mod tests {
852 + use super::*;
853 +
854 + fn script(supervisor: Supervisor, health_url: Option<&str>) -> String {
855 + build_remote_start_script("apps/my-app", "my-app", supervisor, health_url)
856 + }
857 +
858 + #[test]
859 + fn resolve_supervisor_defaults_to_nohup() {
860 + for pm in [
861 + None,
862 + Some(""),
863 + Some("nohup"),
864 + Some("NoHup"),
865 + Some(" nohup "),
866 + ] {
867 + assert_eq!(resolve_supervisor(pm).unwrap(), Supervisor::Nohup, "{pm:?}");
868 + }
869 + }
870 +
871 + #[test]
872 + fn resolve_supervisor_systemd_is_case_insensitive() {
873 + for pm in ["systemd", "SystemD", " systemd "] {
874 + assert_eq!(resolve_supervisor(Some(pm)).unwrap(), Supervisor::Systemd);
875 + }
876 + }
877 +
878 + #[test]
879 + fn resolve_supervisor_rejects_unknown_value() {
880 + for pm in ["system", "pm2", "supervisord"] {
881 + let msg = resolve_supervisor(Some(pm)).unwrap_err().to_string();
882 + assert!(msg.contains(pm), "names the offending value: {msg}");
883 + assert!(msg.contains("nohup") && msg.contains("systemd"), "{msg}");
884 + }
885 + }
886 +
887 + #[test]
888 + fn systemd_script_restarts_the_user_unit() {
889 + let s = script(Supervisor::Systemd, None);
890 + assert!(s.contains("systemctl --user restart \"$UNIT\""));
891 + assert!(s.contains("UNIT=\"$PROCESS_NAME.service\""));
892 + assert!(s.contains("XDG_RUNTIME_DIR=\"/run/user/$(id -u)\""));
893 + assert!(s.contains("systemctl --user is-active"));
894 + assert!(!s.contains("nohup"));
895 + assert!(!s.contains("kill"));
896 + assert!(!s.contains("sudo"));
897 + }
898 +
899 + #[test]
900 + fn nohup_script_keeps_the_pidof_flow() {
901 + let s = script(Supervisor::Nohup, None);
902 + assert!(s.contains("pidof"));
903 + assert!(s.contains("nohup \"./$PROCESS_NAME\""));
904 + assert!(!s.contains("systemctl"));
905 + }
906 +
907 + #[test]
908 + fn both_scripts_back_up_and_swap_before_restarting() {
909 + for supervisor in [Supervisor::Nohup, Supervisor::Systemd] {
910 + let s = script(supervisor, None);
911 + let backup = s
912 + .find("cp -p \"$PROCESS_NAME\" \"$PROCESS_NAME.previous\"")
913 + .unwrap();
914 + let swap = s
915 + .find("mv -f \"$PROCESS_NAME.new\" \"$PROCESS_NAME\"")
916 + .unwrap();
917 + let restart = s.find("if restart_service && check_service").unwrap();
918 + assert!(backup < swap && swap < restart, "{supervisor:?}");
919 + assert!(s.contains("Rolled back"), "{supervisor:?}");
920 + }
921 + }
922 +
923 + #[test]
924 + fn health_url_controls_the_curl_poll() {
925 + for supervisor in [Supervisor::Nohup, Supervisor::Systemd] {
926 + let with = script(supervisor, Some("http://127.0.0.1:8080/health"));
927 + assert!(with.contains("curl -fsS"));
928 + assert!(with.contains("HEALTH_URL='http://127.0.0.1:8080/health'"));
929 + assert!(!script(supervisor, None).contains("curl"));
930 + }
931 + }
932 +
933 + #[test]
934 + fn health_url_needs_both_port_and_path() {
935 + let url = resolve_health_url(Some(8080), Some("/health")).unwrap();
936 + assert_eq!(url.as_deref(), Some("http://127.0.0.1:8080/health"));
937 + assert_eq!(resolve_health_url(None, Some("/health")).unwrap(), None);
938 + assert_eq!(resolve_health_url(Some(8080), None).unwrap(), None);
939 + assert_eq!(resolve_health_url(Some(8080), Some("")).unwrap(), None);
940 + }
941 +
942 + #[test]
943 + fn health_path_validation_rejects_unsafe_values() {
944 + for path in [
945 + "health", "/a b", "/x;rm", "/$(id)", "/a'b", "/foo%ZZ", "/foo%", "/foo%2",
946 + ] {
947 + assert!(
948 + resolve_health_url(Some(8080), Some(path)).is_err(),
949 + "{path}"
950 + );
951 + }
952 + }
953 +
954 + #[test]
955 + fn health_path_accepts_valid_percent_escapes() {
956 + let url = resolve_health_url(Some(8080), Some("/a%20b")).unwrap();
957 + assert_eq!(url.as_deref(), Some("http://127.0.0.1:8080/a%20b"));
958 + }
959 +
960 + #[test]
961 + fn binary_name_with_single_quote_is_quoted() {
962 + let s = build_remote_start_script("apps/x", "a'b", Supervisor::Nohup, None);
963 + assert!(s.contains("PROCESS_NAME='a'\"'\"'b'"));
964 + }
965 +
966 + #[test]
967 + fn every_script_variant_is_valid_bash() {
968 + for supervisor in [Supervisor::Nohup, Supervisor::Systemd] {
969 + for health in [None, Some("http://127.0.0.1:8080/health")] {
970 + let mut child = Command::new("bash")
971 + .args(["-n", "-s"])
972 + .stdin(Stdio::piped())
973 + .stdout(Stdio::piped())
974 + .stderr(Stdio::piped())
975 + .spawn()
976 + .expect("bash is available");
977 + child
978 + .stdin
979 + .take()
980 + .unwrap()
981 + .write_all(script(supervisor, health).as_bytes())
982 + .unwrap();
983 + let out = child.wait_with_output().unwrap();
984 + assert!(
985 + out.status.success(),
986 + "{supervisor:?} {health:?}: {}",
987 + String::from_utf8_lossy(&out.stderr)
988 + );
989 + }
990 + }
991 + }
992 +}
crates/cli/src/cloud-deploy/process_migrate.rs
+1
@@ -200,6 +200,7 @@ fn strip_project(project: &Project) -> Project {
200 pm2_env: None,
201 process_manager: None,
202 port: None,
203 + health_path: None,
204 shared_lib: None,
205 compile_cmd: None,
206 install_command: None,
crates/smbcloud-model/src/deploy_config.rs
+4 -2
@@ -35,9 +35,11 @@ pub struct DeployConfig {
35 pub pm2_app: Option<String>,
36 #[serde(default)]
37 pub pm2_env: Option<std::collections::HashMap<String, serde_json::Value>>,
38 - /// Runtime supervisor for `nextjs-ssr` apps: `"pm2"` (default) or
39 - /// `"systemd"`. When `systemd`, deploys restart via a git-owned
38 + /// Runtime supervisor. `nextjs-ssr`: `"pm2"` (default) or `"systemd"`;
39 + /// `systemd` restarts via a git-owned
40 /// `systemctl --user restart <pm2_app>.service` instead of `pm2`.
41 + /// `rust`: `"nohup"` (default) or `"systemd"`, restarting
42 + /// `<binary_name>.service` the same way.
43 #[serde(default)]
44 pub process_manager: Option<String>,
45 #[serde(default)]
crates/smbcloud-model/src/project.rs
+10 -3
@@ -103,16 +103,23 @@ pub struct Project {
103 /// Populated from the server-side App record; not written to `.smb/config.toml`.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub pm2_env: Option<std::collections::HashMap<String, serde_json::Value>>,
106 - /// Runtime supervisor for `nextjs-ssr` apps: `"pm2"` (default) or `"systemd"`.
107 - /// When `"systemd"`, the deploy restart step drives a git-owned
106 + /// Runtime supervisor. For `nextjs-ssr` apps: `"pm2"` (default) or
107 + /// `"systemd"`. When `"systemd"`, the deploy restart step drives a git-owned
108 /// `systemctl --user restart <pm2_app>.service` (created by the
109 /// pm2-to-systemd migration) instead of `pm2 delete`/`pm2 start`.
110 /// `pm2_app` is still used as the unit name.
111 - #[serde(default)]
111 + /// For `rust` apps: `"nohup"` (default) or `"systemd"`, with
112 + /// `<binary_name>.service` as the user unit name.
113 pub process_manager: Option<String>,
114 /// Port the standalone server binds to (default: 3000). Must match nginx upstream configuration.
115 #[serde(default)]
116 pub port: Option<u16>,
117 + /// HTTP path polled after a `rust` deploy restart, e.g. `"/health"`. Used
118 + /// together with `port`: the deploy requests
119 + /// `http://127.0.0.1:<port><health_path>` until it succeeds, and rolls back
120 + /// to the previous binary if it does not.
121 + #[serde(default)]
122 + pub health_path: Option<String>,
123 /// Path to a shared lib directory to rsync to the server before deploying,
124 /// e.g. "lib". Used by Rails apps that depend on native gems built from
125 /// monorepo-level source. Relative to the repo root.