@smbcloud / cli / commits / 05d722b

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 {