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
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
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);
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,
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,
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
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 {
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
+}