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 {