docker_wrapper/
debug.rs

1//! Debugging and reliability features for Docker commands.
2
3use crate::command::{CommandExecutor, CommandOutput};
4use crate::error::Result;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7use tokio::time::sleep;
8use tracing::{debug, info, instrument, trace, warn};
9
10/// Configuration for dry-run mode and debugging
11#[derive(Debug, Clone)]
12pub struct DebugConfig {
13    /// Enable dry-run mode (commands are not executed)
14    pub dry_run: bool,
15
16    /// Enable verbose output
17    pub verbose: bool,
18
19    /// Log all commands to this vector
20    pub command_log: Arc<Mutex<Vec<String>>>,
21
22    /// Custom prefix for dry-run output
23    pub dry_run_prefix: String,
24}
25
26impl Default for DebugConfig {
27    fn default() -> Self {
28        Self {
29            dry_run: false,
30            verbose: false,
31            command_log: Arc::new(Mutex::new(Vec::new())),
32            dry_run_prefix: "[DRY RUN]".to_string(),
33        }
34    }
35}
36
37impl DebugConfig {
38    /// Create a new debug configuration
39    #[must_use]
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Enable dry-run mode
45    #[must_use]
46    pub fn dry_run(mut self, enabled: bool) -> Self {
47        self.dry_run = enabled;
48        self
49    }
50
51    /// Enable verbose output
52    #[must_use]
53    pub fn verbose(mut self, enabled: bool) -> Self {
54        self.verbose = enabled;
55        self
56    }
57
58    /// Set custom dry-run prefix
59    #[must_use]
60    pub fn dry_run_prefix(mut self, prefix: impl Into<String>) -> Self {
61        self.dry_run_prefix = prefix.into();
62        self
63    }
64
65    /// Log a command
66    pub fn log_command(&self, command: &str) {
67        if let Ok(mut log) = self.command_log.lock() {
68            log.push(command.to_string());
69        }
70    }
71
72    /// Get logged commands
73    #[must_use]
74    pub fn get_command_log(&self) -> Vec<String> {
75        self.command_log
76            .lock()
77            .map(|log| log.clone())
78            .unwrap_or_default()
79    }
80
81    /// Clear command log
82    pub fn clear_log(&self) {
83        if let Ok(mut log) = self.command_log.lock() {
84            log.clear();
85        }
86    }
87}
88
89/// Type alias for retry callback function
90pub type RetryCallback = Arc<dyn Fn(u32, &str) + Send + Sync>;
91
92/// Retry policy for handling transient failures
93#[derive(Clone)]
94pub struct RetryPolicy {
95    /// Maximum number of attempts
96    pub max_attempts: u32,
97
98    /// Backoff strategy between retries
99    pub backoff: BackoffStrategy,
100
101    /// Callback for retry events
102    pub on_retry: Option<RetryCallback>,
103}
104
105impl std::fmt::Debug for RetryPolicy {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("RetryPolicy")
108            .field("max_attempts", &self.max_attempts)
109            .field("backoff", &self.backoff)
110            .field("on_retry", &self.on_retry.is_some())
111            .finish()
112    }
113}
114
115/// Backoff strategy for retries
116#[derive(Debug, Clone)]
117pub enum BackoffStrategy {
118    /// Fixed delay between retries
119    Fixed(Duration),
120
121    /// Linear increase in delay
122    Linear {
123        /// Initial delay duration
124        initial: Duration,
125        /// Increment added for each retry
126        increment: Duration,
127    },
128
129    /// Exponential backoff
130    Exponential {
131        /// Initial delay duration
132        initial: Duration,
133        /// Maximum delay duration
134        max: Duration,
135        /// Multiplier for exponential growth
136        multiplier: f64,
137    },
138}
139
140impl Default for RetryPolicy {
141    fn default() -> Self {
142        Self {
143            max_attempts: 3,
144            backoff: BackoffStrategy::Exponential {
145                initial: Duration::from_millis(100),
146                max: Duration::from_secs(10),
147                multiplier: 2.0,
148            },
149            on_retry: None,
150        }
151    }
152}
153
154impl RetryPolicy {
155    /// Create a new retry policy
156    #[must_use]
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Set maximum attempts
162    #[must_use]
163    pub fn max_attempts(mut self, attempts: u32) -> Self {
164        self.max_attempts = attempts;
165        self
166    }
167
168    /// Set backoff strategy
169    #[must_use]
170    pub fn backoff(mut self, strategy: BackoffStrategy) -> Self {
171        self.backoff = strategy;
172        self
173    }
174
175    /// Set retry callback
176    #[must_use]
177    pub fn on_retry<F>(mut self, callback: F) -> Self
178    where
179        F: Fn(u32, &str) + Send + Sync + 'static,
180    {
181        self.on_retry = Some(Arc::new(callback));
182        self
183    }
184
185    /// Calculate delay for attempt number
186    #[must_use]
187    pub fn calculate_delay(&self, attempt: u32) -> Duration {
188        match &self.backoff {
189            BackoffStrategy::Fixed(delay) => *delay,
190
191            BackoffStrategy::Linear { initial, increment } => {
192                *initial + (*increment * (attempt - 1))
193            }
194
195            BackoffStrategy::Exponential {
196                initial,
197                max,
198                multiplier,
199            } => {
200                #[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
201                let delay_ms =
202                    initial.as_millis() as f64 * multiplier.powi(attempt.saturating_sub(1) as i32);
203                #[allow(clippy::cast_precision_loss)]
204                let capped_ms = delay_ms.min(max.as_millis() as f64);
205                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
206                Duration::from_millis(capped_ms as u64)
207            }
208        }
209    }
210
211    /// Check if an error is retryable
212    #[must_use]
213    pub fn is_retryable(error_str: &str) -> bool {
214        // Common retryable Docker errors
215        error_str.contains("connection refused")
216            || error_str.contains("timeout")
217            || error_str.contains("temporarily unavailable")
218            || error_str.contains("resource temporarily unavailable")
219            || error_str.contains("Cannot connect to the Docker daemon")
220            || error_str.contains("503 Service Unavailable")
221            || error_str.contains("502 Bad Gateway")
222    }
223}
224
225/// Enhanced command executor with debugging features
226#[derive(Debug, Clone)]
227pub struct DebugExecutor {
228    /// Base executor
229    pub executor: CommandExecutor,
230
231    /// Debug configuration
232    pub debug_config: DebugConfig,
233
234    /// Retry policy
235    pub retry_policy: Option<RetryPolicy>,
236}
237
238impl DebugExecutor {
239    /// Create a new debug executor
240    #[must_use]
241    pub fn new() -> Self {
242        Self {
243            executor: CommandExecutor::new(),
244            debug_config: DebugConfig::default(),
245            retry_policy: None,
246        }
247    }
248
249    /// Enable dry-run mode
250    #[must_use]
251    pub fn dry_run(mut self, enabled: bool) -> Self {
252        self.debug_config = self.debug_config.dry_run(enabled);
253        self
254    }
255
256    /// Enable verbose mode
257    #[must_use]
258    pub fn verbose(mut self, enabled: bool) -> Self {
259        self.debug_config = self.debug_config.verbose(enabled);
260        self
261    }
262
263    /// Set retry policy
264    #[must_use]
265    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
266        self.retry_policy = Some(policy);
267        self
268    }
269
270    /// Execute a command with debug features
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if the command fails after all retry attempts
275    #[instrument(
276        name = "debug.execute",
277        skip(self, args),
278        fields(
279            command = %command_name,
280            dry_run = self.debug_config.dry_run,
281            has_retry = self.retry_policy.is_some(),
282        )
283    )]
284    pub async fn execute_command(
285        &self,
286        command_name: &str,
287        args: Vec<String>,
288    ) -> Result<CommandOutput> {
289        let command_str = format!("docker {} {}", command_name, args.join(" "));
290
291        // Log the command
292        self.debug_config.log_command(&command_str);
293
294        trace!(command = %command_str, "executing debug command");
295
296        // Verbose output
297        if self.debug_config.verbose {
298            eprintln!("[VERBOSE] Executing: {command_str}");
299        }
300
301        // Dry-run mode
302        if self.debug_config.dry_run {
303            let message = format!(
304                "{} Would execute: {}",
305                self.debug_config.dry_run_prefix, command_str
306            );
307            eprintln!("{message}");
308            info!(command = %command_str, "dry-run mode, command not executed");
309
310            return Ok(CommandOutput {
311                stdout: message,
312                stderr: String::new(),
313                exit_code: 0,
314                success: true,
315            });
316        }
317
318        // Execute with retry if configured
319        if let Some(ref policy) = self.retry_policy {
320            self.execute_with_retry(command_name, args, policy).await
321        } else {
322            self.executor.execute_command(command_name, args).await
323        }
324    }
325
326    /// Execute command with retry logic
327    #[instrument(
328        name = "debug.retry",
329        skip(self, args, policy),
330        fields(
331            command = %command_name,
332            max_attempts = policy.max_attempts,
333        )
334    )]
335    async fn execute_with_retry(
336        &self,
337        command_name: &str,
338        args: Vec<String>,
339        policy: &RetryPolicy,
340    ) -> Result<CommandOutput> {
341        let mut attempt = 0;
342        let mut last_error = None;
343
344        debug!(
345            max_attempts = policy.max_attempts,
346            "starting command execution with retry"
347        );
348
349        while attempt < policy.max_attempts {
350            attempt += 1;
351
352            trace!(attempt = attempt, "executing attempt");
353
354            if self.debug_config.verbose && attempt > 1 {
355                eprintln!(
356                    "[VERBOSE] Retry attempt {}/{}",
357                    attempt, policy.max_attempts
358                );
359            }
360
361            match self
362                .executor
363                .execute_command(command_name, args.clone())
364                .await
365            {
366                Ok(output) => {
367                    if attempt > 1 {
368                        info!(attempt = attempt, "command succeeded after retry");
369                    }
370                    return Ok(output);
371                }
372                Err(e) => {
373                    let error_str = e.to_string();
374
375                    // Check if retryable
376                    if !RetryPolicy::is_retryable(&error_str) {
377                        debug!(
378                            error = %error_str,
379                            "error is not retryable, failing immediately"
380                        );
381                        return Err(e);
382                    }
383
384                    // Last attempt?
385                    if attempt >= policy.max_attempts {
386                        warn!(
387                            attempt = attempt,
388                            max_attempts = policy.max_attempts,
389                            error = %error_str,
390                            "all retry attempts exhausted"
391                        );
392                        return Err(e);
393                    }
394
395                    // Call retry callback
396                    if let Some(ref callback) = policy.on_retry {
397                        callback(attempt, &error_str);
398                    }
399
400                    // Calculate and apply delay
401                    let delay = policy.calculate_delay(attempt);
402
403                    #[allow(clippy::cast_possible_truncation)]
404                    let delay_ms = delay.as_millis() as u64;
405                    warn!(
406                        attempt = attempt,
407                        max_attempts = policy.max_attempts,
408                        error = %error_str,
409                        delay_ms = delay_ms,
410                        "command failed, will retry after delay"
411                    );
412
413                    if self.debug_config.verbose {
414                        eprintln!("[VERBOSE] Waiting {delay:?} before retry");
415                    }
416                    sleep(delay).await;
417
418                    last_error = Some(e);
419                }
420            }
421        }
422
423        Err(last_error.unwrap_or_else(|| crate::error::Error::custom("Retry failed")))
424    }
425
426    /// Get command history
427    #[must_use]
428    pub fn get_command_log(&self) -> Vec<String> {
429        self.debug_config.get_command_log()
430    }
431
432    /// Clear command history
433    pub fn clear_log(&self) {
434        self.debug_config.clear_log();
435    }
436}
437
438impl Default for DebugExecutor {
439    fn default() -> Self {
440        Self::new()
441    }
442}
443
444/// Helper for previewing commands without executing them
445pub struct DryRunPreview {
446    /// Commands that would be executed
447    pub commands: Vec<String>,
448}
449
450impl DryRunPreview {
451    /// Create a preview of commands
452    #[must_use]
453    pub fn new(commands: Vec<String>) -> Self {
454        Self { commands }
455    }
456
457    /// Print the preview
458    pub fn print(&self) {
459        if self.commands.is_empty() {
460            println!("No commands would be executed.");
461            return;
462        }
463
464        println!("Would execute the following commands:");
465        for (i, cmd) in self.commands.iter().enumerate() {
466            println!("  {}. {}", i + 1, cmd);
467        }
468    }
469}
470
471impl std::fmt::Display for DryRunPreview {
472    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473        if self.commands.is_empty() {
474            return write!(f, "No commands would be executed.");
475        }
476
477        writeln!(f, "Would execute the following commands:")?;
478        for (i, cmd) in self.commands.iter().enumerate() {
479            writeln!(f, "  {}. {}", i + 1, cmd)?;
480        }
481        Ok(())
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_debug_config() {
491        let config = DebugConfig::new()
492            .dry_run(true)
493            .verbose(true)
494            .dry_run_prefix("[TEST]");
495
496        assert!(config.dry_run);
497        assert!(config.verbose);
498        assert_eq!(config.dry_run_prefix, "[TEST]");
499    }
500
501    #[test]
502    fn test_retry_policy_delay() {
503        // Fixed backoff
504        let policy = RetryPolicy::new().backoff(BackoffStrategy::Fixed(Duration::from_millis(100)));
505        assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
506        assert_eq!(policy.calculate_delay(3), Duration::from_millis(100));
507
508        // Linear backoff
509        let policy = RetryPolicy::new().backoff(BackoffStrategy::Linear {
510            initial: Duration::from_millis(100),
511            increment: Duration::from_millis(50),
512        });
513        assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
514        assert_eq!(policy.calculate_delay(2), Duration::from_millis(150));
515        assert_eq!(policy.calculate_delay(3), Duration::from_millis(200));
516
517        // Exponential backoff
518        let policy = RetryPolicy::new().backoff(BackoffStrategy::Exponential {
519            initial: Duration::from_millis(100),
520            max: Duration::from_secs(1),
521            multiplier: 2.0,
522        });
523        assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
524        assert_eq!(policy.calculate_delay(2), Duration::from_millis(200));
525        assert_eq!(policy.calculate_delay(3), Duration::from_millis(400));
526        assert_eq!(policy.calculate_delay(5), Duration::from_secs(1)); // Capped at max
527    }
528
529    #[test]
530    fn test_retryable_errors() {
531        assert!(RetryPolicy::is_retryable("connection refused"));
532        assert!(RetryPolicy::is_retryable("operation timeout"));
533        assert!(RetryPolicy::is_retryable(
534            "Cannot connect to the Docker daemon"
535        ));
536        assert!(!RetryPolicy::is_retryable("image not found"));
537        assert!(!RetryPolicy::is_retryable("permission denied"));
538    }
539
540    #[test]
541    fn test_command_logging() {
542        let config = DebugConfig::new();
543        config.log_command("docker ps -a");
544        config.log_command("docker run nginx");
545
546        let log = config.get_command_log();
547        assert_eq!(log.len(), 2);
548        assert_eq!(log[0], "docker ps -a");
549        assert_eq!(log[1], "docker run nginx");
550
551        config.clear_log();
552        assert_eq!(config.get_command_log().len(), 0);
553    }
554
555    #[test]
556    fn test_dry_run_preview() {
557        let commands = vec![
558            "docker pull nginx".to_string(),
559            "docker run -d nginx".to_string(),
560        ];
561
562        let preview = DryRunPreview::new(commands);
563        let output = preview.to_string();
564
565        assert!(output.contains("Would execute"));
566        assert!(output.contains("1. docker pull nginx"));
567        assert!(output.contains("2. docker run -d nginx"));
568    }
569}