release/v0.7.4
claude/smbcloud-github-app-k9641p
development
feature/apps
feature/build-release
feature/ci-docs
feature/ci-ruby-gems
feature/cicd
feature/completion
feature/dist-nuget
feature/fix-ci-sdk-pypi-rubygem
feature/fix-clippy-on-stable-channel
feature/fix-deployment-script
feature/github-email
feature/gresiq-v1
feature/project-new-structure
feature/rsync-deploy
feature/ruby-runner
feature/sdk-npm
feature/sdk-workflows
feature/smbcloud-cli-release-npm
feature/smbcloud-deploy
feature/wasm
main
release/v0.7.4
| 1 | //! Shipping a source tree to its deploy target. |
| 2 | //! |
| 3 | //! A [`Transport`] takes files that are ready to ship and puts them on the |
| 4 | //! server. Today the only one is [`RsyncTransport`] (rsync over SSH with the |
| 5 | //! server host key pinned). A git-smart-HTTP transport can implement the same |
| 6 | //! trait later without changing any callers. |
| 7 | |
| 8 | use crate::{error::DeployError, known_hosts, report::Reporter}; |
| 9 | use anyhow::anyhow; |
| 10 | use std::{ |
| 11 | io::Write, |
| 12 | path::{Path, PathBuf}, |
| 13 | process::Command, |
| 14 | }; |
| 15 | use tempfile::NamedTempFile; |
| 16 | |
| 17 | /// Ships a source tree to its deploy target. |
| 18 | pub trait Transport { |
| 19 | /// Transfer `source` to the target, reporting progress via `reporter`. |
| 20 | fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError>; |
| 21 | } |
| 22 | |
| 23 | /// rsync over SSH with the server's host key pinned (see [`known_hosts`]). |
| 24 | /// |
| 25 | /// Auth and topology are resolved by the caller and passed in: the front-end |
| 26 | /// owns where the identity file lives and how the remote path is derived, so the |
| 27 | /// engine makes no assumptions about `~/.ssh` layout or project naming. |
| 28 | pub struct RsyncTransport { |
| 29 | host: String, |
| 30 | remote_path: String, |
| 31 | identity_file: PathBuf, |
| 32 | } |
| 33 | |
| 34 | impl RsyncTransport { |
| 35 | pub fn new(host: String, remote_path: String, identity_file: PathBuf) -> Self { |
| 36 | Self { |
| 37 | host, |
| 38 | remote_path, |
| 39 | identity_file, |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | impl Transport for RsyncTransport { |
| 45 | fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError> { |
| 46 | // Pin the server's host key to a temp known_hosts file for this transfer, |
| 47 | // protecting every user against DNS/BGP hijacking even on untrusted |
| 48 | // networks. The file is deleted when it drops at the end of this call. |
| 49 | let mut known_hosts_file = NamedTempFile::new() |
| 50 | .map_err(|e| anyhow!("Failed to create temp known_hosts file: {e}"))?; |
| 51 | writeln!(known_hosts_file, "{}", known_hosts::for_host(&self.host)) |
| 52 | .map_err(|e| anyhow!("Failed to write known_hosts: {e}"))?; |
| 53 | |
| 54 | // Fully hardened SSH: only the pinned host key is trusted, only the given |
| 55 | // identity is used, no password fallback, never prompt. |
| 56 | let ssh_command = format!( |
| 57 | "ssh -i {identity} \ |
| 58 | -o StrictHostKeyChecking=yes \ |
| 59 | -o UserKnownHostsFile={known_hosts} \ |
| 60 | -o IdentitiesOnly=yes \ |
| 61 | -o PasswordAuthentication=no \ |
| 62 | -o BatchMode=yes", |
| 63 | identity = self.identity_file.to_string_lossy(), |
| 64 | known_hosts = known_hosts_file.path().display(), |
| 65 | ); |
| 66 | |
| 67 | // `rsync -a src/` syncs contents, not the directory itself, so both |
| 68 | // sides need a trailing slash. |
| 69 | let source_str = source.to_string_lossy(); |
| 70 | let source_with_slash = if source_str.ends_with('/') { |
| 71 | source_str.into_owned() |
| 72 | } else { |
| 73 | format!("{source_str}/") |
| 74 | }; |
| 75 | let remote_with_slash = if self.remote_path.ends_with('/') { |
| 76 | self.remote_path.clone() |
| 77 | } else { |
| 78 | format!("{}/", self.remote_path) |
| 79 | }; |
| 80 | let destination = format!("git@{}:{}", self.host, remote_with_slash); |
| 81 | |
| 82 | reporter.step_start(&format!("Syncing {source_with_slash} -> {destination}")); |
| 83 | |
| 84 | // known_hosts_file must stay alive until rsync exits so SSH can read it. |
| 85 | let output = Command::new("rsync") |
| 86 | .args([ |
| 87 | "-a", |
| 88 | "--exclude=.git", |
| 89 | "--exclude=.smb", |
| 90 | "-e", |
| 91 | &ssh_command, |
| 92 | &source_with_slash, |
| 93 | &destination, |
| 94 | ]) |
| 95 | .output(); |
| 96 | drop(known_hosts_file); |
| 97 | |
| 98 | match output { |
| 99 | Ok(result) if result.status.success() => { |
| 100 | for line in String::from_utf8_lossy(&result.stderr).lines() { |
| 101 | reporter.remote_line(line); |
| 102 | } |
| 103 | reporter.step_done("Deployment complete via rsync."); |
| 104 | Ok(()) |
| 105 | } |
| 106 | Ok(result) => { |
| 107 | let code = result.status.code().unwrap_or(-1); |
| 108 | reporter.step_fail(&format!("rsync exited with status {code}")); |
| 109 | for line in String::from_utf8_lossy(&result.stderr).lines() { |
| 110 | reporter.remote_line(line); |
| 111 | } |
| 112 | Err(DeployError::Other(anyhow!( |
| 113 | "rsync exited with status {code}" |
| 114 | ))) |
| 115 | } |
| 116 | Err(e) => { |
| 117 | reporter.step_fail(&format!("Failed to launch rsync: {e}. Is rsync installed?")); |
| 118 | Err(DeployError::Other(anyhow!("Failed to launch rsync: {e}"))) |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | } |