@smbcloud / cli / commits / 2aad9c1

Deploy Rust projects by uploading local Linux binary and restarting remotely

Deploy Rust projects by uploading local Linux binary and restarting remotely

Seto Elkahfi committed May 17, 2026 at 07:46 UTC 2aad9c156d712066fe4db450a0b81b33c8cef1b9
3 files changed +362 -95
crates/cli/src/deploy/process_deploy.rs
+1 -1
@@ -136,7 +136,7 @@ pub async fn process_deploy(
136 return process_deploy_rails(env, config).await;
137 }
138
139 - // Route Rust service projects: rsync source tree, then run a remote Cargo build script.
139 + // Route Rust service projects: build a Linux binary locally, upload it over rsync, then restart it over SSH.
140 if config.project.kind.as_deref() == Some("rust") {
141 return process_deploy_rust(env, config).await;
142 }
crates/cli/src/deploy/process_deploy_rust.rs
+351 -91
@@ -16,32 +16,33 @@ use {
16 smbcloud_utils::config::Config,
17 spinners::{Spinner, Spinners},
18 std::{
19 + fs,
20 io::Write,
20 - path::Path,
21 - process::{Command, Stdio},
21 + path::{Path, PathBuf},
22 + process::{Command, Output, Stdio},
23 },
24 tempfile::NamedTempFile,
25 + toml::Value,
26 };
27
26 -/// Deploys a Rust service by syncing source to the server and running a remote
27 -/// Cargo build step.
28 +const DEFAULT_RUST_TARGET: &str = "x86_64-unknown-linux-gnu";
29 +
30 +/// Deploys a Rust service by cross-compiling a Linux binary locally, uploading
31 +/// only the executable, and restarting it on the remote host.
32 ///
33 /// Required config fields:
34 /// - `kind = "rust"`
31 -/// - `path` — remote app directory on the server
35 +/// - `path` — remote app directory on the server
36 ///
37 /// Optional config fields:
34 -/// - `source` — local source directory (defaults to current directory)
35 -/// - `compile_cmd` — remote command sequence to run inside `path` after the
36 -/// upload. When omitted, the CLI runs `cargo build --release`.
37 -///
38 -/// Typical `compile_cmd` for a systemd-managed service:
39 -/// cargo build --release && sudo systemctl restart smbcloud-mail-imap
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 pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<CommandResult> {
42 let source = config.project.source.as_deref().unwrap_or(".");
43 let remote_path = config.project.path.as_deref().ok_or_else(|| {
44 anyhow!(fail_message(
44 - "path not set in .smb/config.toml (e.g. path = \"apps/mail/smbcloud-mail-imap\")"
45 + "path not set in .smb/config.toml (e.g. path = \"apps/rest-api/my-rust-app\")"
46 ))
47 })?;
48
@@ -53,17 +54,21 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
54 ))));
55 }
56
57 + let binary_name = resolve_binary_name(&config, source_dir)?;
58 + let rust_target = config
59 + .project
60 + .rust_target
61 + .as_deref()
62 + .unwrap_or(DEFAULT_RUST_TARGET);
63 +
64 + let binary_path = build_local_binary(source, rust_target, &binary_name)?;
65 +
66 let access_token = crate::token::get_smb_token::get_smb_token(env)?;
67 let user = me(env, client(), &access_token).await?;
68 let runner = config.project.runner;
69 let rsync_host = runner.rsync_host();
70
61 - let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
62 - let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user.id));
63 - let identity_file_str = identity_file.to_string_lossy().into_owned();
64 -
71 let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
66 -
72 let created_deployment = create_deployment(
73 env,
74 client(),
@@ -78,51 +83,87 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
83 .await
84 .ok();
85
86 + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
87 + let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user.id));
88 + let identity_file_str = identity_file.to_string_lossy().into_owned();
89 +
90 let mut known_hosts_file = NamedTempFile::new()
91 .map_err(|error| anyhow!("Failed to create temp known_hosts file: {}", error))?;
92 writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
93 .map_err(|error| anyhow!("Failed to write known_hosts: {}", error))?;
94
86 - let ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
95 + let mut prepare_spinner = Spinner::new(
96 + Spinners::SimpleDotsScrolling,
97 + succeed_message(&format!("Preparing {} on server…", remote_path)),
98 + );
99
88 - let source_with_slash = if source.ends_with('/') {
89 - source.to_owned()
90 - } else {
91 - format!("{}/", source)
92 - };
100 + let prepare_script = build_remote_prepare_script(remote_path);
101 + let prepare_output = run_remote_script(
102 + &identity_file_str,
103 + &known_hosts_file,
104 + &rsync_host,
105 + &prepare_script,
106 + )
107 + .map_err(|error| {
108 + anyhow!(fail_message(&format!(
109 + "Failed to prepare remote directory: {}",
110 + error
111 + )))
112 + })?;
113
114 + if !prepare_output.status.success() {
115 + prepare_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote prepare failed."));
116 + print_output_details(&prepare_output);
117 + drop(known_hosts_file);
118 + mark_failed(
119 + &deploy_ref,
120 + &created_deployment,
121 + &config,
122 + env,
123 + &access_token,
124 + )
125 + .await;
126 + return Err(anyhow!(fail_message(&format!(
127 + "Failed to prepare remote directory '{}'",
128 + remote_path
129 + ))));
130 + }
131 +
132 + prepare_spinner.stop_and_persist(
133 + &succeed_symbol(),
134 + succeed_message("Remote directory ready."),
135 + );
136 +
137 + let ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
138 let remote_with_slash = if remote_path.ends_with('/') {
139 remote_path.to_owned()
140 } else {
141 format!("{}/", remote_path)
142 };
99 -
143 let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
144 + let binary_path_str = binary_path.to_string_lossy().into_owned();
145
146 let mut upload_spinner = Spinner::new(
147 Spinners::Hamburger,
104 - succeed_message(&format!("Uploading Rust project {}…", source)),
148 + succeed_message(&format!("Uploading {}…", binary_name)),
149 );
150
151 let upload_output = Command::new("rsync")
152 .args([
153 "-az",
110 - "--delete",
111 - "--exclude=.git",
112 - "--exclude=.smb",
113 - "--exclude=target",
154 + "--chmod=755",
155 "-e",
156 &ssh_command,
116 - &source_with_slash,
157 + &binary_path_str,
158 &destination,
159 ])
160 .output()
161 .map_err(|error| anyhow!(fail_message(&format!("Failed to launch rsync: {}", error))))?;
162
163 if !upload_output.status.success() {
123 - drop(known_hosts_file);
164 upload_spinner.stop_and_persist(&fail_symbol(), fail_message("Upload failed."));
125 - let stderr = String::from_utf8_lossy(&upload_output.stderr);
165 + print_output_details(&upload_output);
166 + drop(known_hosts_file);
167 mark_failed(
168 &deploy_ref,
169 &created_deployment,
@@ -132,79 +173,32 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
173 )
174 .await;
175 return Err(anyhow!(fail_message(&format!(
135 - "rsync exited with status {}: {}",
136 - upload_output.status.code().unwrap_or(-1),
137 - stderr.trim()
176 + "Failed to upload '{}' to '{}'",
177 + binary_name, remote_path
178 ))));
179 }
180
181 upload_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Upload complete."));
182
143 - let remote_script_body = match config.project.compile_cmd.as_deref() {
144 - Some(command) => command.to_owned(),
145 - None => "cargo build --release".to_owned(),
146 - };
147 -
148 - let deploy_script = format!(
149 - r#"set -e
150 -source ~/.profile 2>/dev/null || true
151 -source ~/.bashrc 2>/dev/null || true
152 -APP_PATH=\"{remote_path}\"
153 -
154 -if [ ! -d \"$APP_PATH\" ]; then
155 - echo \"Error: $APP_PATH is not a directory.\"
156 - exit 1
157 -fi
158 -
159 -cd \"$APP_PATH\"
160 -{remote_script_body}
161 -echo \"Done.\"
162 -"#,
163 - remote_path = remote_path,
164 - remote_script_body = remote_script_body,
165 - );
166 -
183 let mut remote_spinner = Spinner::new(
184 Spinners::SimpleDotsScrolling,
169 - succeed_message("Running remote Rust deploy script…"),
185 + succeed_message(&format!("Starting {} on server…", binary_name)),
186 );
187
172 - let mut child = Command::new("ssh")
173 - .args(build_ssh_args(
174 - &identity_file_str,
175 - &known_hosts_file,
176 - &rsync_host,
177 - ))
178 - .stdin(Stdio::piped())
179 - .stdout(Stdio::piped())
180 - .stderr(Stdio::piped())
181 - .spawn()
182 - .map_err(|error| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", error))))?;
183 -
184 - if let Some(mut stdin) = child.stdin.take() {
185 - stdin
186 - .write_all(deploy_script.as_bytes())
187 - .map_err(|error| anyhow!("Failed to write deploy script to SSH stdin: {}", error))?;
188 - }
189 -
190 - let ssh_output = child
191 - .wait_with_output()
192 - .map_err(|error| anyhow!("Failed to wait for SSH process: {}", error))?;
188 + let deploy_script = build_remote_start_script(remote_path, &binary_name);
189 + let ssh_output = run_remote_script(
190 + &identity_file_str,
191 + &known_hosts_file,
192 + &rsync_host,
193 + &deploy_script,
194 + )
195 + .map_err(|error| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", error))))?;
196
197 drop(known_hosts_file);
198
199 if !ssh_output.status.success() {
200 remote_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote deploy failed."));
198 - let stderr = String::from_utf8_lossy(&ssh_output.stderr);
199 - let stdout = String::from_utf8_lossy(&ssh_output.stdout);
200 - let details = if !stderr.trim().is_empty() {
201 - stderr
202 - } else {
203 - stdout
204 - };
205 - if !details.trim().is_empty() {
206 - eprintln!("{}", details.trim());
207 - }
201 + print_output_details(&ssh_output);
202 mark_failed(
203 &deploy_ref,
204 &created_deployment,
@@ -251,6 +245,217 @@ echo \"Done.\"
245 })
246 }
247
248 +fn resolve_binary_name(config: &Config, source_dir: &Path) -> Result<String> {
249 + if let Some(binary_name) = config.project.binary_name.as_deref() {
250 + let binary_name = binary_name.trim();
251 + if binary_name.is_empty() {
252 + return Err(anyhow!(fail_message(
253 + "binary_name in .smb/config.toml cannot be empty."
254 + )));
255 + }
256 + return Ok(binary_name.to_owned());
257 + }
258 +
259 + let cargo_toml_path = source_dir.join("Cargo.toml");
260 + if !cargo_toml_path.exists() {
261 + return Err(anyhow!(fail_message(&format!(
262 + "Cargo.toml not found at '{}'. Set 'source' to the crate directory or add 'binary_name' to .smb/config.toml.",
263 + cargo_toml_path.display()
264 + ))));
265 + }
266 +
267 + let cargo_toml = fs::read_to_string(&cargo_toml_path).map_err(|error| {
268 + anyhow!(fail_message(&format!(
269 + "Failed to read '{}': {}",
270 + cargo_toml_path.display(),
271 + error
272 + )))
273 + })?;
274 +
275 + let manifest: Value = toml::from_str(&cargo_toml).map_err(|error| {
276 + anyhow!(fail_message(&format!(
277 + "Failed to parse '{}': {}",
278 + cargo_toml_path.display(),
279 + error
280 + )))
281 + })?;
282 +
283 + let package_name = manifest
284 + .get("package")
285 + .and_then(Value::as_table)
286 + .and_then(|package| package.get("name"))
287 + .and_then(Value::as_str)
288 + .map(str::trim)
289 + .filter(|name| !name.is_empty())
290 + .ok_or_else(|| {
291 + anyhow!(fail_message(
292 + "Could not determine Rust binary name from Cargo.toml. Add 'binary_name' to .smb/config.toml."
293 + ))
294 + })?;
295 +
296 + Ok(package_name.to_owned())
297 +}
298 +
299 +fn build_local_binary(source: &str, rust_target: &str, binary_name: &str) -> Result<PathBuf> {
300 + println!();
301 +
302 + let mut build_command;
303 + let build_tool;
304 + let is_native = native_linux_target() == Some(rust_target);
305 +
306 + if !is_native && command_exists("cargo-zigbuild") {
307 + build_tool = "cargo zigbuild";
308 + build_command = Command::new("cargo");
309 + build_command.args([
310 + "zigbuild",
311 + "--release",
312 + "--target",
313 + rust_target,
314 + "--bin",
315 + binary_name,
316 + ]);
317 + } else if !is_native && command_exists("cross") {
318 + build_tool = "cross";
319 + build_command = Command::new("cross");
320 + build_command.args([
321 + "build",
322 + "--release",
323 + "--target",
324 + rust_target,
325 + "--bin",
326 + binary_name,
327 + ]);
328 + } else if is_native {
329 + build_tool = "cargo";
330 + build_command = Command::new("cargo");
331 + build_command.args([
332 + "build",
333 + "--release",
334 + "--target",
335 + rust_target,
336 + "--bin",
337 + binary_name,
338 + ]);
339 + } else {
340 + return Err(anyhow!(fail_message(&format!(
341 + "Cross-compilation tooling is required to build target '{}'. Install `cargo-zigbuild` (recommended) or `cross`, or run deploy from a matching Linux host.",
342 + rust_target
343 + ))));
344 + }
345 +
346 + let status = build_command
347 + .current_dir(source)
348 + .status()
349 + .map_err(|error| {
350 + anyhow!(fail_message(&format!(
351 + "Failed to spawn '{} build' for target '{}': {}",
352 + build_tool, rust_target, error
353 + )))
354 + })?;
355 +
356 + println!();
357 +
358 + if !status.success() {
359 + return Err(anyhow!(fail_message(&format!(
360 + "'{} build --release --target {} --bin {}' exited with status {}",
361 + build_tool, rust_target, binary_name, status
362 + ))));
363 + }
364 +
365 + let binary_path = Path::new(source)
366 + .join("target")
367 + .join(rust_target)
368 + .join("release")
369 + .join(binary_name);
370 +
371 + if !binary_path.exists() {
372 + return Err(anyhow!(fail_message(&format!(
373 + "Built binary not found at '{}'.",
374 + binary_path.display()
375 + ))));
376 + }
377 +
378 + println!(
379 + "{} {}",
380 + succeed_symbol(),
381 + succeed_message(&format!("Built {} for {}.", binary_name, rust_target))
382 + );
383 +
384 + Ok(binary_path)
385 +}
386 +
387 +fn build_remote_prepare_script(remote_path: &str) -> String {
388 + format!(
389 + r#"set -e
390 +APP_PATH={remote_path}
391 +
392 +case "$APP_PATH" in
393 + /*) ;;
394 + *) APP_PATH="$HOME/$APP_PATH" ;;
395 +esac
396 +
397 +mkdir -p "$APP_PATH"
398 +echo "Prepared $APP_PATH"
399 +"#,
400 + remote_path = shell_single_quote(remote_path),
401 + )
402 +}
403 +
404 +fn build_remote_start_script(remote_path: &str, binary_name: &str) -> String {
405 + format!(
406 + r#"set -e
407 +APP_PATH={remote_path}
408 +PROCESS_NAME={binary_name}
409 +
410 +case "$APP_PATH" in
411 + /*) ;;
412 + *) APP_PATH="$HOME/$APP_PATH" ;;
413 +esac
414 +
415 +if [ ! -d "$APP_PATH" ]; then
416 + echo "Error: $APP_PATH is not a directory."
417 + exit 1
418 +fi
419 +
420 +cd "$APP_PATH"
421 +
422 +if [ ! -f "$PROCESS_NAME" ]; then
423 + echo "Error: $PROCESS_NAME does not exist in $APP_PATH."
424 + exit 1
425 +fi
426 +
427 +chmod +x "$PROCESS_NAME"
428 +
429 +PID=$(pidof "$PROCESS_NAME" 2>/dev/null || true)
430 +
431 +if [ -n "$PID" ]; then
432 + echo "Stopping $PROCESS_NAME ($PID)..."
433 + kill "$PID" 2>/dev/null || true
434 + sleep 2
435 + if kill -0 "$PID" 2>/dev/null; then
436 + echo "Force-killing $PROCESS_NAME ($PID)..."
437 + kill -9 "$PID" 2>/dev/null || true
438 + fi
439 +fi
440 +
441 +echo "Starting $PROCESS_NAME..."
442 +nohup "./$PROCESS_NAME" >> "$APP_PATH/$PROCESS_NAME.log" 2>&1 &
443 +sleep 1
444 +
445 +NEW_PID=$(pidof "$PROCESS_NAME" 2>/dev/null || true)
446 +if [ -z "$NEW_PID" ]; then
447 + echo "Error: failed to start $PROCESS_NAME."
448 + exit 1
449 +fi
450 +
451 +echo "Started $PROCESS_NAME as $NEW_PID"
452 +echo "Done."
453 +"#,
454 + remote_path = shell_single_quote(remote_path),
455 + binary_name = shell_single_quote(binary_name),
456 + )
457 +}
458 +
459 fn build_ssh_command(identity_file: &str, known_hosts_file: &NamedTempFile) -> String {
460 format!(
461 "ssh -i {identity} \\
@@ -288,6 +493,61 @@ fn build_ssh_args<'a>(
493 ]
494 }
495
496 +fn run_remote_script(
497 + identity_file: &str,
498 + known_hosts_file: &NamedTempFile,
499 + rsync_host: &str,
500 + script: &str,
501 +) -> Result<Output> {
502 + let mut child = Command::new("ssh")
503 + .args(build_ssh_args(identity_file, known_hosts_file, rsync_host))
504 + .stdin(Stdio::piped())
505 + .stdout(Stdio::piped())
506 + .stderr(Stdio::piped())
507 + .spawn()
508 + .map_err(|error| anyhow!("Failed to spawn SSH: {}", error))?;
509 +
510 + if let Some(mut stdin) = child.stdin.take() {
511 + stdin
512 + .write_all(script.as_bytes())
513 + .map_err(|error| anyhow!("Failed to write deploy script to SSH stdin: {}", error))?;
514 + }
515 +
516 + child
517 + .wait_with_output()
518 + .map_err(|error| anyhow!("Failed to wait for SSH process: {}", error))
519 +}
520 +
521 +fn print_output_details(output: &Output) {
522 + let stderr = String::from_utf8_lossy(&output.stderr);
523 + let stdout = String::from_utf8_lossy(&output.stdout);
524 + let details = if !stderr.trim().is_empty() {
525 + stderr
526 + } else {
527 + stdout
528 + };
529 +
530 + if !details.trim().is_empty() {
531 + eprintln!("{}", details.trim());
532 + }
533 +}
534 +
535 +fn command_exists(command: &str) -> bool {
536 + Command::new(command).arg("--version").output().is_ok()
537 +}
538 +
539 +fn native_linux_target() -> Option<&'static str> {
540 + match (std::env::consts::OS, std::env::consts::ARCH) {
541 + ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
542 + ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
543 + _ => None,
544 + }
545 +}
546 +
547 +fn shell_single_quote(value: &str) -> String {
548 + format!("'{}'", value.replace('\'', "'\"'\"'"))
549 +}
550 +
551 async fn mark_failed(
552 deploy_ref: &str,
553 created_deployment: &Option<smbcloud_model::project::Deployment>,
crates/smbcloud-model/src/project.rs
+10 -3
@@ -64,10 +64,11 @@ pub struct Project {
64 pub source_path: Option<String>,
65 pub created_at: DateTime<Utc>,
66 pub updated_at: DateTime<Utc>,
67 - /// Deployment kind, e.g. "vite-spa". Absent for server-side runners.
67 + /// Deployment kind, e.g. "vite-spa", "nextjs-ssr", or "rust".
68 pub kind: Option<String>,
69 - /// Local source directory to build from, e.g. "frontend/connected-devices".
70 - /// Used by vite-spa deploys as the working directory for the build step.
69 + /// Local source directory to build from, e.g. "frontend/connected-devices"
70 + /// or a Rust crate root like ".".
71 + /// Used by local-build deploys such as vite-spa, nextjs-ssr, and rust.
72 /// Distinct from `path`, which is the remote destination on the server.
73 pub source: Option<String>,
74 /// Build output directory relative to `source`, e.g. "dist".
@@ -87,6 +88,12 @@ pub struct Project {
88 /// SSH command to run on the server after rsyncing the shared lib,
89 /// e.g. "cd ~/lib/gems/gem_error_codes && rbenv local 3.4.2 && bundle install && bundle exec rake compile".
90 pub compile_cmd: Option<String>,
91 + /// Rust binary filename to upload and restart, e.g. "onde-cloud".
92 + /// When absent, the CLI falls back to the Cargo package name.
93 + pub binary_name: Option<String>,
94 + /// Rust target triple used for local cross-compilation before upload,
95 + /// e.g. "x86_64-unknown-linux-gnu".
96 + pub rust_target: Option<String>,
97 }
98
99 impl Display for Project {