Add Rails deploy handler with shared lib and compile support
- Route Rails projects to new process_deploy_rails handler - Support rsync of shared lib and SSH compile step before git push - Extend Project model with shared_lib and compile_cmd fields
Seto committed
Mar 24, 2026 at 00:51 UTC
05d722b5d8746a0c58530bb53afa22871485062b
4 files changed
+406
crates/cli/src/deploy/mod.rs
+1
@@ -4,6 +4,7 @@ mod git;
4
mod known_hosts;
5
pub mod process_deploy;
6
pub mod process_deploy_nextjs_ssr;
7
+pub mod process_deploy_rails;
8
pub mod process_deploy_vite_spa;
9
mod remote_messages;
10
pub(crate) mod rsync_deploy;
crates/cli/src/deploy/process_deploy.rs
+6
@@ -8,6 +8,7 @@ use {
8
detect_runner::detect_runner,
9
git::remote_deployment_setup,
10
process_deploy_nextjs_ssr::process_deploy_nextjs_ssr,
11
+ process_deploy_rails::process_deploy_rails,
12
process_deploy_vite_spa::process_deploy_vite_spa,
13
remote_messages::{build_next_app, start_server},
14
rsync_deploy::rsync_deploy,
@@ -129,6 +130,11 @@ pub async fn process_deploy(
130
return process_deploy_nextjs_ssr(env, config).await;
131
}
132
133
+ // Route Rails projects: rsync shared lib, SSH compile native gem, git force-push sub-project.
134
+ if config.project.kind.as_deref() == Some("rails") {
135
+ return process_deploy_rails(env, config).await;
136
+ }
137
+
138
match config.project.deployment_method {
139
DeploymentMethod::Rsync => {
140
// For rsync deployments the runner is known from config — no framework
crates/cli/src/deploy/process_deploy_rails.rs
new
+392
@@ -0,0 +1,392 @@
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_model::project::{DeploymentPayload, DeploymentStatus},
11
+ smbcloud_network::environment::Environment,
12
+ smbcloud_networking_account::me::me,
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
+ process::{Command, Stdio},
21
+ },
22
+ tempfile::NamedTempFile,
23
+};
24
+
25
+/// Deploys a Ruby on Rails sub-project from a monorepo.
26
+///
27
+/// Replicates the GitHub Actions workflow:
28
+///
29
+/// 1. rsync shared `lib/` directory to the server (e.g. Rust-based gems)
30
+/// 2. SSH: compile native gem extensions on the server
31
+/// 3. git init inside the sub-project directory, commit all files,
32
+/// force-push to the server's bare repo (triggers post-receive deploy)
33
+/// 4. Record deployment status via the API
34
+///
35
+/// Config fields used:
36
+/// - `source` — local sub-project directory (e.g. "backend/musik88-web")
37
+/// - `repository` — bare repo name on the server (e.g. "musik88-production")
38
+/// - `runner` — must be `Ruby` (determines git host: api-1.smbcloud.xyz)
39
+/// - `shared_lib` — optional path to shared lib directory to rsync (e.g. "lib")
40
+/// - `compile_cmd` — optional SSH command to run after rsync (e.g. gem compilation)
41
+pub async fn process_deploy_rails(env: Environment, config: Config) -> Result<CommandResult> {
42
+ let source = config.project.source.as_deref().ok_or_else(|| {
43
+ anyhow!(fail_message(
44
+ "source not set in .smb/config.toml (e.g. source = \"backend/my-rails-app\")"
45
+ ))
46
+ })?;
47
+
48
+ let repository = config.project.repository.as_deref().ok_or_else(|| {
49
+ anyhow!(fail_message(
50
+ "repository not set in .smb/config.toml (e.g. repository = \"my-app-production\")"
51
+ ))
52
+ })?;
53
+
54
+ let source_dir = std::path::Path::new(source);
55
+ if !source_dir.exists() {
56
+ return Err(anyhow!(fail_message(&format!(
57
+ "Source path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
58
+ source
59
+ ))));
60
+ }
61
+
62
+ let access_token = crate::token::get_smb_token::get_smb_token(env)?;
63
+ let user = me(env, client(), &access_token).await?;
64
+ let runner = config.project.runner;
65
+ let rsync_host = runner.rsync_host();
66
+
67
+ let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
68
+ let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user.id));
69
+ let identity_file_str = identity_file.to_string_lossy().into_owned();
70
+
71
+ // ── Step 1: rsync shared lib to the server ───────────────────────────────
72
+ //
73
+ // Some Rails apps depend on shared libraries in the monorepo root (e.g.
74
+ // Rust-based gems that must be compiled on the server). If `shared_lib`
75
+ // is set, rsync that directory to the git user's home on the server.
76
+
77
+ if let Some(shared_lib) = config.project.shared_lib.as_deref() {
78
+ let shared_lib_path = std::path::Path::new(shared_lib);
79
+ if !shared_lib_path.exists() {
80
+ return Err(anyhow!(fail_message(&format!(
81
+ "Shared lib path '{}' does not exist.",
82
+ shared_lib
83
+ ))));
84
+ }
85
+
86
+ let mut lib_spinner = Spinner::new(
87
+ Spinners::SimpleDotsScrolling,
88
+ succeed_message(&format!("Uploading shared lib {}…", shared_lib)),
89
+ );
90
+
91
+ let mut known_hosts_file = NamedTempFile::new()
92
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
93
+ writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
94
+ .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
95
+
96
+ let ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
97
+
98
+ let output = Command::new("rsync")
99
+ .args([
100
+ "-r",
101
+ "-e",
102
+ &ssh_command,
103
+ &format!("./{}", shared_lib),
104
+ &format!("git@{}:~/", rsync_host),
105
+ ])
106
+ .output()
107
+ .map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
108
+
109
+ drop(known_hosts_file);
110
+
111
+ if !output.status.success() {
112
+ let stderr = String::from_utf8_lossy(&output.stderr);
113
+ lib_spinner.stop_and_persist(&fail_symbol(), fail_message("Shared lib upload failed."));
114
+ return Err(anyhow!(fail_message(&format!(
115
+ "rsync of '{}' failed: {}",
116
+ shared_lib,
117
+ stderr.trim()
118
+ ))));
119
+ }
120
+
121
+ lib_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Shared lib uploaded."));
122
+ }
123
+
124
+ // ── Step 2: compile native extensions on the server ──────────────────────
125
+ //
126
+ // If `compile_cmd` is set, run it on the server via SSH. This is typically
127
+ // used to compile Rust-based gems that are part of the shared lib.
128
+
129
+ if let Some(compile_cmd) = config.project.compile_cmd.as_deref() {
130
+ let mut compile_spinner = Spinner::new(
131
+ Spinners::SimpleDotsScrolling,
132
+ succeed_message("Compiling native extensions on server…"),
133
+ );
134
+
135
+ let mut known_hosts_file = NamedTempFile::new()
136
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
137
+ writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
138
+ .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
139
+
140
+ let compile_script = format!(
141
+ r#"set -e
142
+source ~/.profile 2>/dev/null || true
143
+source ~/.bashrc 2>/dev/null || true
144
+{compile_cmd}
145
+"#,
146
+ compile_cmd = compile_cmd,
147
+ );
148
+
149
+ let mut child = Command::new("ssh")
150
+ .args(build_ssh_args(
151
+ &identity_file_str,
152
+ &known_hosts_file,
153
+ &rsync_host,
154
+ ))
155
+ .stdin(Stdio::piped())
156
+ .spawn()
157
+ .map_err(|e| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", e))))?;
158
+
159
+ if let Some(mut stdin) = child.stdin.take() {
160
+ stdin
161
+ .write_all(compile_script.as_bytes())
162
+ .map_err(|e| anyhow!("Failed to write compile script to SSH stdin: {}", e))?;
163
+ }
164
+
165
+ let ssh_status = child
166
+ .wait()
167
+ .map_err(|e| anyhow!("Failed to wait for SSH process: {}", e))?;
168
+
169
+ drop(known_hosts_file);
170
+
171
+ if !ssh_status.success() {
172
+ compile_spinner.stop_and_persist(
173
+ &fail_symbol(),
174
+ fail_message("Compilation failed on server. See output above."),
175
+ );
176
+ return Err(anyhow!(fail_message(&format!(
177
+ "Remote compile command exited with status {}",
178
+ ssh_status
179
+ ))));
180
+ }
181
+
182
+ compile_spinner.stop_and_persist(
183
+ &succeed_symbol(),
184
+ succeed_message("Native extensions compiled."),
185
+ );
186
+ }
187
+
188
+ // ── Step 3: record deployment as Started ─────────────────────────────────
189
+
190
+ let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
191
+
192
+ let created_deployment = create_deployment(
193
+ env,
194
+ client(),
195
+ &access_token,
196
+ config.project.id,
197
+ DeploymentPayload {
198
+ commit_hash: deploy_ref.clone(),
199
+ status: DeploymentStatus::Started,
200
+ },
201
+ )
202
+ .await
203
+ .ok();
204
+
205
+ // ── Step 4: git init + force-push the sub-project directory ──────────────
206
+ //
207
+ // In a monorepo, the sub-project is not the git root. The server expects
208
+ // a push of just the sub-project as if it were a standalone repo. We
209
+ // create a temporary git repo inside the sub-project, commit everything,
210
+ // and force-push to the server's bare repo. This mirrors the GA workflow:
211
+ //
212
+ // cd backend/musik88-web
213
+ // git init && git add . && git commit -m "Deploy" && git push --force
214
+ //
215
+ // The temporary .git directory is cleaned up after the push.
216
+
217
+ let git_host = runner.git_host();
218
+ let remote_url = format!("{}:{}.git", git_host, repository);
219
+
220
+ let mut push_spinner = Spinner::new(
221
+ Spinners::Hamburger,
222
+ succeed_message(&format!("Deploying {} → {}…", source, remote_url)),
223
+ );
224
+
225
+ // Use system git rather than libgit2 — we need SSH credential support
226
+ // via the smbCloud identity file, and force-push to a temporary remote.
227
+ // The GIT_SSH_COMMAND env var injects our hardened SSH options.
228
+
229
+ let mut known_hosts_file = NamedTempFile::new()
230
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
231
+ writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
232
+ .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
233
+
234
+ let git_ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
235
+
236
+ // Helper to run a git command in the source directory with our SSH config.
237
+ let run_git = |args: &[&str]| -> Result<()> {
238
+ let output = Command::new("git")
239
+ .args(args)
240
+ .current_dir(source)
241
+ .env("GIT_SSH_COMMAND", &git_ssh_command)
242
+ .output()
243
+ .map_err(|e| anyhow!("Failed to run git {}: {}", args.join(" "), e))?;
244
+
245
+ if !output.status.success() {
246
+ let stderr = String::from_utf8_lossy(&output.stderr);
247
+ return Err(anyhow!("git {} failed: {}", args.join(" "), stderr.trim()));
248
+ }
249
+ Ok(())
250
+ };
251
+
252
+ // Check if the source directory is already a git repo (it shouldn't be —
253
+ // it's a subdirectory of the monorepo). If a .git exists, it's leftover
254
+ // from a previous deploy; remove it to start clean.
255
+ let sub_git_dir = source_dir.join(".git");
256
+ if sub_git_dir.exists() {
257
+ std::fs::remove_dir_all(&sub_git_dir)
258
+ .map_err(|e| anyhow!("Failed to remove leftover .git in '{}': {}", source, e))?;
259
+ }
260
+
261
+ let push_result = (|| -> Result<()> {
262
+ run_git(&["init", "-b", "main"])?;
263
+ run_git(&["add", "."])?;
264
+ run_git(&[
265
+ "-c",
266
+ "user.email=deploy@smbcloud.xyz",
267
+ "-c",
268
+ "user.name=smb-deploy",
269
+ "commit",
270
+ "-m",
271
+ "Deploy to production",
272
+ ])?;
273
+ run_git(&["remote", "add", "smbcloud", &remote_url])?;
274
+ run_git(&["push", "--set-upstream", "smbcloud", "main", "--force"])?;
275
+ Ok(())
276
+ })();
277
+
278
+ // Always clean up the temporary .git directory, even if the push failed.
279
+ if sub_git_dir.exists() {
280
+ let _ = std::fs::remove_dir_all(&sub_git_dir);
281
+ }
282
+
283
+ drop(known_hosts_file);
284
+
285
+ match push_result {
286
+ Ok(()) => {
287
+ push_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Push complete."));
288
+ }
289
+ Err(e) => {
290
+ push_spinner.stop_and_persist(&fail_symbol(), fail_message("Push failed."));
291
+ mark_failed(
292
+ &deploy_ref,
293
+ &created_deployment,
294
+ &config,
295
+ env,
296
+ &access_token,
297
+ )
298
+ .await;
299
+ return Err(anyhow!(fail_message(&format!("{}", e))));
300
+ }
301
+ }
302
+
303
+ // ── Step 5: mark deployment as Done ──────────────────────────────────────
304
+
305
+ if let Some(ref deployment) = created_deployment {
306
+ match update(
307
+ env,
308
+ client(),
309
+ access_token,
310
+ config.project.id,
311
+ deployment.id,
312
+ DeploymentPayload {
313
+ commit_hash: deploy_ref,
314
+ status: DeploymentStatus::Done,
315
+ },
316
+ )
317
+ .await
318
+ {
319
+ Ok(_) => println!("App is running {}", succeed_symbol()),
320
+ Err(e) => eprintln!("Error updating deployment status to Done: {}", e),
321
+ }
322
+ }
323
+
324
+ Ok(CommandResult {
325
+ spinner: Spinner::new(Spinners::Hamburger, String::new()),
326
+ symbol: succeed_symbol(),
327
+ msg: succeed_message("Deployment complete."),
328
+ })
329
+}
330
+
331
+/// Build the SSH command string used for rsync's `-e` flag.
332
+fn build_ssh_command(identity_file: &str, known_hosts_file: &NamedTempFile) -> String {
333
+ format!(
334
+ "ssh -i {identity} \
335
+ -o StrictHostKeyChecking=yes \
336
+ -o UserKnownHostsFile={known_hosts} \
337
+ -o IdentitiesOnly=yes \
338
+ -o PasswordAuthentication=no \
339
+ -o BatchMode=yes",
340
+ identity = identity_file,
341
+ known_hosts = known_hosts_file.path().display(),
342
+ )
343
+}
344
+
345
+/// Build SSH argument list for direct `Command::new("ssh")` invocations.
346
+fn build_ssh_args<'a>(
347
+ identity_file: &'a str,
348
+ known_hosts_file: &'a NamedTempFile,
349
+ rsync_host: &'a str,
350
+) -> Vec<String> {
351
+ vec![
352
+ "-i".to_owned(),
353
+ identity_file.to_owned(),
354
+ "-o".to_owned(),
355
+ "StrictHostKeyChecking=yes".to_owned(),
356
+ "-o".to_owned(),
357
+ format!("UserKnownHostsFile={}", known_hosts_file.path().display()),
358
+ "-o".to_owned(),
359
+ "IdentitiesOnly=yes".to_owned(),
360
+ "-o".to_owned(),
361
+ "PasswordAuthentication=no".to_owned(),
362
+ "-o".to_owned(),
363
+ "BatchMode=yes".to_owned(),
364
+ format!("git@{}", rsync_host),
365
+ "bash".to_owned(),
366
+ "-s".to_owned(),
367
+ ]
368
+}
369
+
370
+/// Mark the in-flight deployment record as Failed.
371
+async fn mark_failed(
372
+ deploy_ref: &str,
373
+ created_deployment: &Option<smbcloud_model::project::Deployment>,
374
+ config: &Config,
375
+ env: Environment,
376
+ access_token: &str,
377
+) {
378
+ if let Some(ref deployment) = created_deployment {
379
+ let _ = update(
380
+ env,
381
+ client(),
382
+ access_token.to_owned(),
383
+ config.project.id,
384
+ deployment.id,
385
+ DeploymentPayload {
386
+ commit_hash: deploy_ref.to_owned(),
387
+ status: DeploymentStatus::Failed,
388
+ },
389
+ )
390
+ .await;
391
+ }
392
+}
crates/smbcloud-model/src/project.rs
+7
@@ -64,6 +64,13 @@ pub struct Project {
64
/// PM2 process name to restart after a nextjs-ssr deploy, e.g. "my-app".
65
/// Matches the name passed to `pm2 start` on the server.
66
pub pm2_app: Option<String>,
67
+ /// Path to a shared lib directory to rsync to the server before deploying,
68
+ /// e.g. "lib". Used by Rails apps that depend on native gems built from
69
+ /// monorepo-level source. Relative to the repo root.
70
+ pub shared_lib: Option<String>,
71
+ /// SSH command to run on the server after rsyncing the shared lib,
72
+ /// e.g. "cd ~/lib/gems/gem_error_codes && rbenv local 3.4.2 && bundle install && bundle exec rake compile".
73
+ pub compile_cmd: Option<String>,
74
}
75
76
impl Display for Project {