1use std::collections::HashMap;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::{RemoteExecutor, RemoteOutput};
12
13#[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 #[serde(default)]
22 pub options: HashMap<String, String>,
23}
24
25impl SshConfig {
26 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 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#[derive(Debug)]
59pub struct SshExecutor {
60 machines: HashMap<String, SshConfig>,
62}
63
64impl SshExecutor {
65 pub fn new() -> Self {
67 Self {
68 machines: HashMap::new(),
69 }
70 }
71
72 pub fn add_machine(&mut self, machine_id: String, config: SshConfig) {
74 self.machines.insert(machine_id, config);
75 }
76
77 pub fn remove_machine(&mut self, machine_id: &str) -> Option<SshConfig> {
79 self.machines.remove(machine_id)
80 }
81
82 pub fn get_config(&self, machine_id: &str) -> Option<&SshConfig> {
84 self.machines.get(machine_id)
85 }
86
87 pub fn machine_ids(&self) -> Vec<String> {
89 self.machines.keys().cloned().collect()
90 }
91
92 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 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 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, 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}