Skip to content

Commit c1bbba1

Browse files
committed
Merge branch 'feature/smbcloud-deploy-rust' into development
2 parents f84c599 + fab5f1e commit c1bbba1

8 files changed

Lines changed: 359 additions & 10 deletions

File tree

crates/cli/src/deploy/detect_runner.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
2121
fail_message("Could not get the current path."),
2222
);
2323
anyhow::bail!(
24-
"Could not detect project runner: no package.json, Gemfile, or Package.swift found"
24+
"Could not detect project runner: no package.json, Gemfile, Package.swift, or Cargo.toml found"
2525
);
2626
}
2727
};
@@ -36,11 +36,11 @@ pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
3636
spinner.stop_and_persist(
3737
&fail_symbol(),
3838
fail_message(
39-
"Could not detect project runner: no package.json, Gemfile, or Package.swift found",
39+
"Could not detect project runner: no package.json, Gemfile, Package.swift, or Cargo.toml found",
4040
),
4141
);
4242
anyhow::bail!(
43-
"Could not detect project runner: no package.json, Gemfile, or Package.swift found"
43+
"Could not detect project runner: no package.json, Gemfile, Package.swift, or Cargo.toml found"
4444
);
4545
}
4646
};
@@ -80,6 +80,14 @@ pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
8080
);
8181
}
8282
}
83+
Runner::Rust => {
84+
if Path::new("Cargo.toml").exists() {
85+
spinner.stop_and_persist(
86+
&succeed_symbol(),
87+
succeed_message("Rust 🦀 runner with Cargo project detected"),
88+
);
89+
}
90+
}
8391
};
8492

8593
Ok(runner)

crates/cli/src/deploy/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod known_hosts;
55
pub mod process_deploy;
66
pub mod process_deploy_nextjs_ssr;
77
pub mod process_deploy_rails;
8+
pub mod process_deploy_rust;
89
pub mod process_deploy_vite_spa;
910
mod remote_messages;
1011
pub(crate) mod rsync_deploy;

crates/cli/src/deploy/process_deploy.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use {
99
git::remote_deployment_setup,
1010
process_deploy_nextjs_ssr::process_deploy_nextjs_ssr,
1111
process_deploy_rails::process_deploy_rails,
12+
process_deploy_rust::process_deploy_rust,
1213
process_deploy_vite_spa::process_deploy_vite_spa,
1314
remote_messages::{build_next_app, start_server},
1415
rsync_deploy::rsync_deploy,
@@ -135,6 +136,11 @@ pub async fn process_deploy(
135136
return process_deploy_rails(env, config).await;
136137
}
137138

139+
// Route Rust service projects: rsync source tree, then run a remote Cargo build script.
140+
if config.project.kind.as_deref() == Some("rust") {
141+
return process_deploy_rust(env, config).await;
142+
}
143+
138144
match config.project.deployment_method {
139145
DeploymentMethod::Rsync => {
140146
// For rsync deployments the runner is known from config — no framework
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use {
2+
crate::{
3+
cli::CommandResult,
4+
client,
5+
deploy::known_hosts,
6+
ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
7+
},
8+
anyhow::{anyhow, Result},
9+
chrono::Utc,
10+
smbcloud_auth::me::me,
11+
smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
12+
smbcloud_network::environment::Environment,
13+
smbcloud_networking_project::{
14+
crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
15+
},
16+
smbcloud_utils::config::Config,
17+
spinners::{Spinner, Spinners},
18+
std::{
19+
io::Write,
20+
path::Path,
21+
process::{Command, Stdio},
22+
},
23+
tempfile::NamedTempFile,
24+
};
25+
26+
/// Deploys a Rust service by syncing source to the server and running a remote
27+
/// Cargo build step.
28+
///
29+
/// Required config fields:
30+
/// - `kind = "rust"`
31+
/// - `path` — remote app directory on the server
32+
///
33+
/// 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
40+
pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<CommandResult> {
41+
let source = config.project.source.as_deref().unwrap_or(".");
42+
let remote_path = config.project.path.as_deref().ok_or_else(|| {
43+
anyhow!(fail_message(
44+
"path not set in .smb/config.toml (e.g. path = \"apps/mail/smbcloud-mail-imap\")"
45+
))
46+
})?;
47+
48+
let source_dir = Path::new(source);
49+
if !source_dir.exists() {
50+
return Err(anyhow!(fail_message(&format!(
51+
"Source path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
52+
source
53+
))));
54+
}
55+
56+
let access_token = crate::token::get_smb_token::get_smb_token(env)?;
57+
let user = me(env, client(), &access_token).await?;
58+
let runner = config.project.runner;
59+
let rsync_host = runner.rsync_host();
60+
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+
65+
let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
66+
67+
let created_deployment = create_deployment(
68+
env,
69+
client(),
70+
&access_token,
71+
config.project.id,
72+
DeploymentPayload {
73+
commit_hash: deploy_ref.clone(),
74+
status: DeploymentStatus::Started,
75+
},
76+
)
77+
.await
78+
.ok();
79+
80+
let mut known_hosts_file = NamedTempFile::new()
81+
.map_err(|error| anyhow!("Failed to create temp known_hosts file: {}", error))?;
82+
writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
83+
.map_err(|error| anyhow!("Failed to write known_hosts: {}", error))?;
84+
85+
let ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
86+
87+
let source_with_slash = if source.ends_with('/') {
88+
source.to_owned()
89+
} else {
90+
format!("{}/", source)
91+
};
92+
93+
let remote_with_slash = if remote_path.ends_with('/') {
94+
remote_path.to_owned()
95+
} else {
96+
format!("{}/", remote_path)
97+
};
98+
99+
let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
100+
101+
let mut upload_spinner = Spinner::new(
102+
Spinners::Hamburger,
103+
succeed_message(&format!("Uploading Rust project {}…", source)),
104+
);
105+
106+
let upload_output = Command::new("rsync")
107+
.args([
108+
"-az",
109+
"--delete",
110+
"--exclude=.git",
111+
"--exclude=.smb",
112+
"--exclude=target",
113+
"-e",
114+
&ssh_command,
115+
&source_with_slash,
116+
&destination,
117+
])
118+
.output()
119+
.map_err(|error| anyhow!(fail_message(&format!("Failed to launch rsync: {}", error))))?;
120+
121+
if !upload_output.status.success() {
122+
drop(known_hosts_file);
123+
upload_spinner.stop_and_persist(&fail_symbol(), fail_message("Upload failed."));
124+
let stderr = String::from_utf8_lossy(&upload_output.stderr);
125+
mark_failed(
126+
&deploy_ref,
127+
&created_deployment,
128+
&config,
129+
env,
130+
&access_token,
131+
)
132+
.await;
133+
return Err(anyhow!(fail_message(&format!(
134+
"rsync exited with status {}: {}",
135+
upload_output.status.code().unwrap_or(-1),
136+
stderr.trim()
137+
))));
138+
}
139+
140+
upload_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Upload complete."));
141+
142+
let remote_script_body = match config.project.compile_cmd.as_deref() {
143+
Some(command) => command.to_owned(),
144+
None => "cargo build --release".to_owned(),
145+
};
146+
147+
let deploy_script = format!(
148+
r#"set -e
149+
source ~/.profile 2>/dev/null || true
150+
source ~/.bashrc 2>/dev/null || true
151+
APP_PATH=\"{remote_path}\"
152+
153+
if [ ! -d \"$APP_PATH\" ]; then
154+
echo \"Error: $APP_PATH is not a directory.\"
155+
exit 1
156+
fi
157+
158+
cd \"$APP_PATH\"
159+
{remote_script_body}
160+
echo \"Done.\"
161+
"#,
162+
remote_path = remote_path,
163+
remote_script_body = remote_script_body,
164+
);
165+
166+
let mut remote_spinner = Spinner::new(
167+
Spinners::SimpleDotsScrolling,
168+
succeed_message("Running remote Rust deploy script…"),
169+
);
170+
171+
let mut child = Command::new("ssh")
172+
.args(build_ssh_args(
173+
&identity_file_str,
174+
&known_hosts_file,
175+
&rsync_host,
176+
))
177+
.stdin(Stdio::piped())
178+
.stdout(Stdio::piped())
179+
.stderr(Stdio::piped())
180+
.spawn()
181+
.map_err(|error| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", error))))?;
182+
183+
if let Some(mut stdin) = child.stdin.take() {
184+
stdin
185+
.write_all(deploy_script.as_bytes())
186+
.map_err(|error| anyhow!("Failed to write deploy script to SSH stdin: {}", error))?;
187+
}
188+
189+
let ssh_output = child
190+
.wait_with_output()
191+
.map_err(|error| anyhow!("Failed to wait for SSH process: {}", error))?;
192+
193+
drop(known_hosts_file);
194+
195+
if !ssh_output.status.success() {
196+
remote_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote deploy failed."));
197+
let stderr = String::from_utf8_lossy(&ssh_output.stderr);
198+
let stdout = String::from_utf8_lossy(&ssh_output.stdout);
199+
let details = if !stderr.trim().is_empty() {
200+
stderr
201+
} else {
202+
stdout
203+
};
204+
if !details.trim().is_empty() {
205+
eprintln!("{}", details.trim());
206+
}
207+
mark_failed(
208+
&deploy_ref,
209+
&created_deployment,
210+
&config,
211+
env,
212+
&access_token,
213+
)
214+
.await;
215+
return Err(anyhow!(fail_message(&format!(
216+
"SSH deploy script exited with status {}",
217+
ssh_output.status
218+
))));
219+
}
220+
221+
remote_spinner.stop_and_persist(
222+
&succeed_symbol(),
223+
succeed_message("Remote Rust deploy script completed."),
224+
);
225+
226+
if let Some(ref deployment) = created_deployment {
227+
match update(
228+
env,
229+
client(),
230+
access_token,
231+
config.project.id,
232+
deployment.id,
233+
DeploymentPayload {
234+
commit_hash: deploy_ref,
235+
status: DeploymentStatus::Done,
236+
},
237+
)
238+
.await
239+
{
240+
Ok(_) => println!("App is running {}", succeed_symbol()),
241+
Err(error) => eprintln!("Error updating deployment status to Done: {}", error),
242+
}
243+
}
244+
245+
Ok(CommandResult {
246+
spinner: Spinner::new(Spinners::Hamburger, String::new()),
247+
symbol: succeed_symbol(),
248+
msg: succeed_message("Deployment complete."),
249+
})
250+
}
251+
252+
fn build_ssh_command(identity_file: &str, known_hosts_file: &NamedTempFile) -> String {
253+
format!(
254+
"ssh -i {identity} \\
255+
-o StrictHostKeyChecking=yes \\
256+
-o UserKnownHostsFile={known_hosts} \\
257+
-o IdentitiesOnly=yes \\
258+
-o PasswordAuthentication=no \\
259+
-o BatchMode=yes",
260+
identity = identity_file,
261+
known_hosts = known_hosts_file.path().display(),
262+
)
263+
}
264+
265+
fn build_ssh_args<'a>(
266+
identity_file: &'a str,
267+
known_hosts_file: &'a NamedTempFile,
268+
rsync_host: &'a str,
269+
) -> Vec<String> {
270+
vec![
271+
"-i".to_owned(),
272+
identity_file.to_owned(),
273+
"-o".to_owned(),
274+
"StrictHostKeyChecking=yes".to_owned(),
275+
"-o".to_owned(),
276+
format!("UserKnownHostsFile={}", known_hosts_file.path().display()),
277+
"-o".to_owned(),
278+
"IdentitiesOnly=yes".to_owned(),
279+
"-o".to_owned(),
280+
"PasswordAuthentication=no".to_owned(),
281+
"-o".to_owned(),
282+
"BatchMode=yes".to_owned(),
283+
format!("git@{}", rsync_host),
284+
"bash".to_owned(),
285+
"-s".to_owned(),
286+
]
287+
}
288+
289+
async fn mark_failed(
290+
deploy_ref: &str,
291+
created_deployment: &Option<smbcloud_model::project::Deployment>,
292+
config: &Config,
293+
env: Environment,
294+
access_token: &str,
295+
) {
296+
if let Some(ref deployment) = created_deployment {
297+
let _ = update(
298+
env,
299+
client(),
300+
access_token.to_owned(),
301+
config.project.id,
302+
deployment.id,
303+
DeploymentPayload {
304+
commit_hash: deploy_ref.to_owned(),
305+
status: DeploymentStatus::Failed,
306+
},
307+
)
308+
.await;
309+
}
310+
}

crates/cli/src/deploy/setup_create_new_project.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,13 @@ pub(crate) async fn create_new_project(
7474
}
7575
};
7676

77-
let runners = vec![Runner::NodeJs, Runner::Static, Runner::Ruby, Runner::Swift];
77+
let runners = vec![
78+
Runner::NodeJs,
79+
Runner::Static,
80+
Runner::Ruby,
81+
Runner::Swift,
82+
Runner::Rust,
83+
];
7884
let runner = Select::with_theme(&ColorfulTheme::default())
7985
.items(&runners)
8086
.default(0)

0 commit comments

Comments
 (0)