Skip to main content

torrust_tracker_deployer_lib/adapters/ssh/
error.rs

1//! SSH error types and implementations
2//!
3//! This module defines the error types that can occur during SSH operations,
4//! including connectivity timeouts and command execution failures.
5
6use thiserror::Error;
7
8use crate::shared::command::CommandError;
9
10/// Errors that can occur during SSH operations
11#[derive(Error, Debug)]
12pub enum SshError {
13    /// Failed to establish SSH connectivity within timeout period
14    ///
15    /// This typically means the SSH service is not yet available or the
16    /// instance is still booting. Use `.help()` for detailed troubleshooting.
17    #[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    /// SSH command execution failed
26    ///
27    /// The underlying SSH command execution encountered an error.
28    /// Use `.help()` for detailed troubleshooting steps.
29    #[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    /// Get detailed troubleshooting guidance for this error
41    ///
42    /// This method provides comprehensive troubleshooting steps that can be
43    /// displayed to users when they need more help resolving the error.
44    ///
45    /// # Example
46    ///
47    /// ```rust
48    /// use torrust_tracker_deployer_lib::adapters::ssh::SshError;
49    ///
50    /// let error = SshError::ConnectivityTimeout {
51    ///     host_ip: "192.168.1.100".to_string(),
52    ///     attempts: 30,
53    ///     timeout_seconds: 60,
54    /// };
55    ///
56    /// // Display brief error
57    /// eprintln!("Error: {error}");
58    ///
59    /// // Display detailed help when needed
60    /// eprintln!("\nTroubleshooting:\n{}", error.help());
61    /// ```
62    #[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            // Verify key troubleshooting steps are present
220            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            // Verify actionable commands are present
239            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            // Verify key troubleshooting steps are present
258            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            // Verify actionable commands are present
278            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            // ConnectivityTimeout
286            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            // CommandFailed
294            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            // Verify source is preserved
349            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            // Verify no source for this error type
361            assert!(error.source().is_none());
362        }
363    }
364}