Skip to main content

mur_core/remote/
ssh.rs

1//! SSH-based remote execution.
2//!
3//! Builds SSH command strings and parses output for remote
4//! workflow step execution on machines accessible via SSH.
5
6use std::collections::HashMap;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::{RemoteExecutor, RemoteOutput};
12
13/// SSH connection configuration for a remote machine.
14#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15pub struct SshConfig {
16    pub host: String,
17    pub port: u16,
18    pub user: String,
19    pub identity_file: Option<String>,
20    /// Extra SSH options (e.g., StrictHostKeyChecking=no).
21    #[serde(default)]
22    pub options: HashMap<String, String>,
23}
24
25impl SshConfig {
26    /// Build the SSH command prefix for this configuration.
27    pub fn command_prefix(&self) -> Vec<String> {
28        let mut args = vec!["ssh".to_string()];
29
30        args.push("-p".to_string());
31        args.push(self.port.to_string());
32
33        if let Some(key) = &self.identity_file {
34            args.push("-i".to_string());
35            args.push(key.clone());
36        }
37
38        for (key, val) in &self.options {
39            args.push("-o".to_string());
40            args.push(format!("{key}={val}"));
41        }
42
43        args.push(format!("{}@{}", self.user, self.host));
44        args
45    }
46
47    /// Build a full SSH command string for executing a remote command.
48    pub fn build_command(&self, command: &str, working_dir: Option<&str>) -> String {
49        let prefix = self.command_prefix().join(" ");
50        match working_dir {
51            Some(dir) => format!("{prefix} 'cd {dir} && {command}'"),
52            None => format!("{prefix} '{command}'"),
53        }
54    }
55}
56
57/// SSH-based remote executor.
58#[derive(Debug)]
59pub struct SshExecutor {
60    /// Machine ID → SSH config mapping.
61    machines: HashMap<String, SshConfig>,
62}
63
64impl SshExecutor {
65    /// Create a new SSH executor with no configured machines.
66    pub fn new() -> Self {
67        Self {
68            machines: HashMap::new(),
69        }
70    }
71
72    /// Register a machine with its SSH configuration.
73    pub fn add_machine(&mut self, machine_id: String, config: SshConfig) {
74        self.machines.insert(machine_id, config);
75    }
76
77    /// Remove a machine configuration.
78    pub fn remove_machine(&mut self, machine_id: &str) -> Option<SshConfig> {
79        self.machines.remove(machine_id)
80    }
81
82    /// Get the SSH config for a machine.
83    pub fn get_config(&self, machine_id: &str) -> Option<&SshConfig> {
84        self.machines.get(machine_id)
85    }
86
87    /// List all configured machine IDs.
88    pub fn machine_ids(&self) -> Vec<String> {
89        self.machines.keys().cloned().collect()
90    }
91
92    /// Build the full command string that would be executed for a given machine.
93    pub fn build_command(
94        &self,
95        machine_id: &str,
96        command: &str,
97        working_dir: Option<&str>,
98    ) -> anyhow::Result<String> {
99        let config = self
100            .machines
101            .get(machine_id)
102            .ok_or_else(|| anyhow::anyhow!("unknown machine: {machine_id}"))?;
103        Ok(config.build_command(command, working_dir))
104    }
105
106    /// Parse SSH command output into a RemoteOutput struct.
107    pub fn parse_output(
108        machine_id: &str,
109        stdout: &str,
110        stderr: &str,
111        exit_code: i32,
112        duration_ms: u64,
113    ) -> RemoteOutput {
114        RemoteOutput {
115            stdout: stdout.to_string(),
116            stderr: stderr.to_string(),
117            exit_code,
118            duration_ms,
119            machine_id: machine_id.to_string(),
120        }
121    }
122}
123
124impl Default for SshExecutor {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130impl RemoteExecutor for SshExecutor {
131    fn execute(
132        &self,
133        machine_id: &str,
134        command: &str,
135        working_dir: Option<&str>,
136    ) -> anyhow::Result<RemoteOutput> {
137        let config = self
138            .machines
139            .get(machine_id)
140            .ok_or_else(|| anyhow::anyhow!("unknown machine: {machine_id}"))?;
141
142        let ssh_cmd = config.build_command(command, working_dir);
143        tracing::info!(machine = machine_id, cmd = %ssh_cmd, "executing remote SSH command");
144
145        // Build the actual command to run via the system shell
146        let output = std::process::Command::new("sh")
147            .arg("-c")
148            .arg(&ssh_cmd)
149            .output()
150            .map_err(|e| anyhow::anyhow!("failed to execute SSH command: {e}"))?;
151
152        Ok(RemoteOutput {
153            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
154            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
155            exit_code: output.status.code().unwrap_or(-1),
156            duration_ms: 0, // Timing would require wrapping with Instant
157            machine_id: machine_id.to_string(),
158        })
159    }
160
161    fn ping(&self, machine_id: &str) -> anyhow::Result<bool> {
162        let result = self.execute(machine_id, "echo pong", None)?;
163        Ok(result.success() && result.stdout.trim() == "pong")
164    }
165
166    fn executor_type(&self) -> &str {
167        "ssh"
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn make_config() -> SshConfig {
176        SshConfig {
177            host: "10.0.0.5".into(),
178            port: 22,
179            user: "deploy".into(),
180            identity_file: Some("/home/user/.ssh/id_ed25519".into()),
181            options: HashMap::new(),
182        }
183    }
184
185    #[test]
186    fn test_ssh_config_command_prefix() {
187        let config = make_config();
188        let prefix = config.command_prefix();
189        assert_eq!(prefix[0], "ssh");
190        assert!(prefix.contains(&"-p".to_string()));
191        assert!(prefix.contains(&"22".to_string()));
192        assert!(prefix.contains(&"-i".to_string()));
193        assert!(prefix.contains(&"/home/user/.ssh/id_ed25519".to_string()));
194        assert_eq!(prefix.last().unwrap(), "deploy@10.0.0.5");
195    }
196
197    #[test]
198    fn test_ssh_config_build_command() {
199        let config = make_config();
200        let cmd = config.build_command("ls -la", None);
201        assert!(cmd.contains("ssh"));
202        assert!(cmd.contains("deploy@10.0.0.5"));
203        assert!(cmd.contains("'ls -la'"));
204    }
205
206    #[test]
207    fn test_ssh_config_build_command_with_dir() {
208        let config = make_config();
209        let cmd = config.build_command("make build", Some("/opt/project"));
210        assert!(cmd.contains("cd /opt/project && make build"));
211    }
212
213    #[test]
214    fn test_ssh_config_with_options() {
215        let mut config = make_config();
216        config
217            .options
218            .insert("StrictHostKeyChecking".into(), "no".into());
219        let prefix = config.command_prefix();
220        assert!(prefix.contains(&"-o".to_string()));
221        assert!(prefix.contains(&"StrictHostKeyChecking=no".to_string()));
222    }
223
224    #[test]
225    fn test_ssh_executor_add_and_list() {
226        let mut executor = SshExecutor::new();
227        executor.add_machine("prod-1".into(), make_config());
228        executor.add_machine("prod-2".into(), make_config());
229
230        let mut ids = executor.machine_ids();
231        ids.sort();
232        assert_eq!(ids, vec!["prod-1", "prod-2"]);
233    }
234
235    #[test]
236    fn test_ssh_executor_remove() {
237        let mut executor = SshExecutor::new();
238        executor.add_machine("prod-1".into(), make_config());
239        let removed = executor.remove_machine("prod-1");
240        assert!(removed.is_some());
241        assert!(executor.machine_ids().is_empty());
242    }
243
244    #[test]
245    fn test_ssh_executor_get_config() {
246        let mut executor = SshExecutor::new();
247        executor.add_machine("prod-1".into(), make_config());
248        let config = executor.get_config("prod-1").unwrap();
249        assert_eq!(config.host, "10.0.0.5");
250    }
251
252    #[test]
253    fn test_ssh_executor_build_command() {
254        let mut executor = SshExecutor::new();
255        executor.add_machine("prod-1".into(), make_config());
256
257        let cmd = executor
258            .build_command("prod-1", "cargo test", Some("/app"))
259            .unwrap();
260        assert!(cmd.contains("cd /app && cargo test"));
261    }
262
263    #[test]
264    fn test_ssh_executor_build_command_unknown() {
265        let executor = SshExecutor::new();
266        assert!(executor.build_command("unknown", "ls", None).is_err());
267    }
268
269    #[test]
270    fn test_parse_output() {
271        let output = SshExecutor::parse_output("m1", "hello\n", "", 0, 150);
272        assert!(output.success());
273        assert_eq!(output.stdout, "hello\n");
274        assert_eq!(output.machine_id, "m1");
275        assert_eq!(output.duration_ms, 150);
276    }
277
278    #[test]
279    fn test_ssh_executor_type() {
280        let executor = SshExecutor::new();
281        assert_eq!(executor.executor_type(), "ssh");
282    }
283
284    #[test]
285    fn test_ssh_config_serialization() {
286        let config = make_config();
287        let json = serde_json::to_string(&config).unwrap();
288        let back: SshConfig = serde_json::from_str(&json).unwrap();
289        assert_eq!(config.host, back.host);
290        assert_eq!(config.port, back.port);
291        assert_eq!(config.user, back.user);
292    }
293
294    #[test]
295    fn test_ssh_executor_default() {
296        let executor = SshExecutor::default();
297        assert!(executor.machine_ids().is_empty());
298    }
299}