torrust_tracker_deployer_lib/adapters/ssh/
error.rs1use thiserror::Error;
7
8use crate::shared::command::CommandError;
9
10#[derive(Error, Debug)]
12pub enum SshError {
13 #[error("Failed to establish SSH connectivity to {host_ip} after {attempts} attempts ({timeout_seconds}s total)
18Tip: Check if instance is fully booted and SSH service is running")]
19 ConnectivityTimeout {
20 host_ip: String,
21 attempts: u32,
22 timeout_seconds: u32,
23 },
24
25 #[error(
30 "SSH command execution failed: {source}
31Tip: Check command syntax and remote host permissions"
32 )]
33 CommandFailed {
34 #[source]
35 source: CommandError,
36 },
37}
38
39impl SshError {
40 #[must_use]
63 pub fn help(&self) -> &'static str {
64 match self {
65 Self::ConnectivityTimeout { .. } => {
66 "SSH Connectivity Timeout - Detailed Troubleshooting:
67
681. Verify the instance is running:
69 - Check VM/server status using your provider tools
70 - Ensure instance has finished booting (may take 30-60s)
71
722. Check SSH service status:
73 - SSH into the server and run: systemctl status ssh
74 - Or check console logs for cloud instances
75
763. Verify network connectivity:
77 - Ping the IP address: ping <host_ip>
78 - Check firewall rules allow port 22
79 - Verify no network issues between hosts
80
814. Check SSH configuration:
82 - Ensure SSH service is enabled on boot
83 - Verify sshd_config allows key authentication
84 - Check SSH key permissions (should be 600 or 400)
85
865. Try manual connection to see specific error:
87 ssh -i <key_path> -o ConnectTimeout=5 -o StrictHostKeyChecking=no <user>@<host_ip>
88
896. Increase timeout if needed:
90 - Slow networks may need more time
91 - Use custom SshConnectionConfig with higher max_retry_attempts or retry_interval_secs
92
93For more information, see the SSH troubleshooting documentation."
94 }
95
96 Self::CommandFailed { .. } => {
97 "SSH Command Failed - Detailed Troubleshooting:
98
991. Check the underlying command error for specific details
100 - Review the error message for hints about what went wrong
101 - Common issues: command not found, permission denied, syntax errors
102
1032. Verify SSH authentication is working:
104 - Test connection: ssh <user>@<host> 'echo test'
105 - Check SSH key permissions (should be 600 or 400)
106 - Verify user has proper access on remote host
107
1083. Ensure remote command is valid:
109 - Test command directly on remote host first
110 - Check for typos in command syntax
111 - Verify required tools/packages are installed
112
1134. Check for permission issues:
114 - Does the SSH user have sufficient privileges?
115 - Try with sudo if appropriate: ssh <user>@<host> 'sudo command'
116 - Review remote host logs for access denied messages
117
1185. Debug with verbose SSH output:
119 ssh -vvv <user>@<host> '<command>'
120
121For more information, see the command execution documentation."
122 }
123 }
124 }
125}
126
127impl crate::shared::Traceable for SshError {
128 fn trace_format(&self) -> String {
129 match self {
130 Self::ConnectivityTimeout {
131 host_ip,
132 attempts,
133 timeout_seconds,
134 } => {
135 format!("SshError: Connectivity timeout to '{host_ip}' after {attempts} attempts ({timeout_seconds} seconds)")
136 }
137 Self::CommandFailed { source } => {
138 format!("SshError: SSH command failed - {source}")
139 }
140 }
141 }
142
143 fn trace_source(&self) -> Option<&dyn crate::shared::Traceable> {
144 match self {
145 Self::ConnectivityTimeout { .. } => None,
146 Self::CommandFailed { source } => Some(source),
147 }
148 }
149
150 fn error_kind(&self) -> crate::shared::ErrorKind {
151 crate::shared::ErrorKind::NetworkConnectivity
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 mod error_messages {
160 use super::*;
161
162 #[test]
163 fn it_should_include_context_in_connectivity_timeout_error() {
164 let error = SshError::ConnectivityTimeout {
165 host_ip: "192.168.1.100".to_string(),
166 attempts: 30,
167 timeout_seconds: 60,
168 };
169
170 let message = error.to_string();
171 assert!(message.contains("192.168.1.100"));
172 assert!(message.contains("30 attempts"));
173 assert!(message.contains("60s total"));
174 }
175
176 #[test]
177 fn it_should_include_brief_tip_in_connectivity_timeout_error() {
178 let error = SshError::ConnectivityTimeout {
179 host_ip: "10.0.0.1".to_string(),
180 attempts: 5,
181 timeout_seconds: 10,
182 };
183
184 let message = error.to_string();
185 assert!(message.contains("Tip:"));
186 assert!(message.contains("instance is fully booted"));
187 }
188
189 #[test]
190 fn it_should_include_brief_tip_in_command_failed_error() {
191 let cmd_error = CommandError::ExecutionFailed {
192 command: "test".to_string(),
193 exit_code: "1".to_string(),
194 stdout: String::new(),
195 stderr: "error".to_string(),
196 };
197
198 let error = SshError::CommandFailed { source: cmd_error };
199
200 let message = error.to_string();
201 assert!(message.contains("Tip:"));
202 assert!(message.contains("command syntax"));
203 }
204 }
205
206 mod help_methods {
207 use super::*;
208
209 #[test]
210 fn it_should_provide_detailed_help_for_connectivity_timeout() {
211 let error = SshError::ConnectivityTimeout {
212 host_ip: "192.168.1.100".to_string(),
213 attempts: 30,
214 timeout_seconds: 60,
215 };
216
217 let help = error.help();
218
219 assert!(help.contains("Verify the instance is running"));
221 assert!(help.contains("Check SSH service status"));
222 assert!(help.contains("Verify network connectivity"));
223 assert!(help.contains("Check SSH configuration"));
224 assert!(help.contains("Try manual connection"));
225 assert!(help.contains("Increase timeout if needed"));
226 }
227
228 #[test]
229 fn it_should_include_actionable_commands_in_connectivity_help() {
230 let error = SshError::ConnectivityTimeout {
231 host_ip: "10.0.0.1".to_string(),
232 attempts: 10,
233 timeout_seconds: 20,
234 };
235
236 let help = error.help();
237
238 assert!(help.contains("systemctl status ssh"));
240 assert!(help.contains("ping <host_ip>"));
241 assert!(help.contains("ssh -i"));
242 assert!(help.contains("SshConnectionConfig"));
243 }
244
245 #[test]
246 fn it_should_provide_detailed_help_for_command_failed() {
247 let cmd_error = CommandError::ExecutionFailed {
248 command: "test".to_string(),
249 exit_code: "1".to_string(),
250 stdout: String::new(),
251 stderr: "error".to_string(),
252 };
253
254 let error = SshError::CommandFailed { source: cmd_error };
255 let help = error.help();
256
257 assert!(help.contains("Check the underlying command error"));
259 assert!(help.contains("Verify SSH authentication"));
260 assert!(help.contains("Ensure remote command is valid"));
261 assert!(help.contains("Check for permission issues"));
262 assert!(help.contains("Debug with verbose SSH output"));
263 }
264
265 #[test]
266 fn it_should_include_actionable_commands_in_command_failed_help() {
267 let cmd_error = CommandError::ExecutionFailed {
268 command: "test".to_string(),
269 exit_code: "1".to_string(),
270 stdout: String::new(),
271 stderr: "error".to_string(),
272 };
273
274 let error = SshError::CommandFailed { source: cmd_error };
275 let help = error.help();
276
277 assert!(help.contains("ssh <user>@<host> 'echo test'"));
279 assert!(help.contains("sudo"));
280 assert!(help.contains("ssh -vvv"));
281 }
282
283 #[test]
284 fn it_should_provide_help_for_all_error_variants() {
285 let error1 = SshError::ConnectivityTimeout {
287 host_ip: "192.168.1.1".to_string(),
288 attempts: 5,
289 timeout_seconds: 10,
290 };
291 assert!(!error1.help().is_empty());
292
293 let cmd_error = CommandError::ExecutionFailed {
295 command: "test".to_string(),
296 exit_code: "1".to_string(),
297 stdout: String::new(),
298 stderr: "error".to_string(),
299 };
300 let error2 = SshError::CommandFailed { source: cmd_error };
301 assert!(!error2.help().is_empty());
302 }
303 }
304
305 mod error_display {
306 use super::*;
307
308 #[test]
309 fn it_should_implement_display_trait() {
310 let error = SshError::ConnectivityTimeout {
311 host_ip: "192.168.1.100".to_string(),
312 attempts: 30,
313 timeout_seconds: 60,
314 };
315
316 let display = format!("{error}");
317 assert!(!display.is_empty());
318 }
319
320 #[test]
321 fn it_should_implement_debug_trait() {
322 let error = SshError::ConnectivityTimeout {
323 host_ip: "192.168.1.100".to_string(),
324 attempts: 30,
325 timeout_seconds: 60,
326 };
327
328 let debug = format!("{error:?}");
329 assert!(!debug.is_empty());
330 }
331 }
332
333 mod error_source_chaining {
334 use super::*;
335 use std::error::Error;
336
337 #[test]
338 fn it_should_preserve_source_error_for_command_failed() {
339 let cmd_error = CommandError::ExecutionFailed {
340 command: "test".to_string(),
341 exit_code: "1".to_string(),
342 stdout: String::new(),
343 stderr: "error".to_string(),
344 };
345
346 let error = SshError::CommandFailed { source: cmd_error };
347
348 assert!(error.source().is_some());
350 }
351
352 #[test]
353 fn it_should_have_no_source_for_connectivity_timeout() {
354 let error = SshError::ConnectivityTimeout {
355 host_ip: "192.168.1.100".to_string(),
356 attempts: 30,
357 timeout_seconds: 60,
358 };
359
360 assert!(error.source().is_none());
362 }
363 }
364}