Skip to main content

docker_wrapper/
debug.rs

1//! Debugging and reliability features for Docker commands.
2
3use crate::command::{CommandExecutor, CommandOutput};
4use crate::error::Result;
5use crate::tracing_compat::{debug, info, trace, warn};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8use tokio::time::sleep;
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    #[cfg_attr(
276        feature = "tracing",
277        tracing::instrument(
278            name = "debug.execute",
279            skip(self, args),
280            fields(
281                command = %command_name,
282                dry_run = self.debug_config.dry_run,
283                has_retry = self.retry_policy.is_some(),
284            )
285        )
286    )]
287    pub async fn execute_command(
288        &self,
289        command_name: &str,
290        args: Vec<String>,
291    ) -> Result<CommandOutput> {
292        let command_str = format!("docker {} {}", command_name, args.join(" "));
293
294        // Log the command
295        self.debug_config.log_command(&command_str);
296
297        trace!(command = %command_str, "executing debug command");
298
299        // Verbose output
300        if self.debug_config.verbose {
301            eprintln!("[VERBOSE] Executing: {command_str}");
302        }
303
304        // Dry-run mode
305        if self.debug_config.dry_run {
306            let message = format!(
307                "{} Would execute: {}",
308                self.debug_config.dry_run_prefix, command_str
309            );
310            eprintln!("{message}");
311            info!(command = %command_str, "dry-run mode, command not executed");
312
313            return Ok(CommandOutput {
314                stdout: message,
315                stderr: String::new(),
316                exit_code: 0,
317                success: true,
318            });
319        }
320
321        // Execute with retry if configured
322        if let Some(ref policy) = self.retry_policy {
323            self.execute_with_retry(command_name, args, policy).await
324        } else {
325            self.executor.execute_command(command_name, args).await
326        }
327    }
328
329    /// Execute command with retry logic
330    #[cfg_attr(
331        feature = "tracing",
332        tracing::instrument(
333            name = "debug.retry",
334            skip(self, args, policy),
335            fields(
336                command = %command_name,
337                max_attempts = policy.max_attempts,
338            )
339        )
340    )]
341    async fn execute_with_retry(
342        &self,
343        command_name: &str,
344        args: Vec<String>,
345        policy: &RetryPolicy,
346    ) -> Result<CommandOutput> {
347        let mut attempt = 0;
348        let mut last_error = None;
349
350        debug!(
351            max_attempts = policy.max_attempts,
352            "starting command execution with retry"
353        );
354
355        while attempt < policy.max_attempts {
356            attempt += 1;
357
358            trace!(attempt = attempt, "executing attempt");
359
360            if self.debug_config.verbose && attempt > 1 {
361                eprintln!(
362                    "[VERBOSE] Retry attempt {}/{}",
363                    attempt, policy.max_attempts
364                );
365            }
366
367            match self
368                .executor
369                .execute_command(command_name, args.clone())
370                .await
371            {
372                Ok(output) => {
373                    if attempt > 1 {
374                        info!(attempt = attempt, "command succeeded after retry");
375                    }
376                    return Ok(output);
377                }
378                Err(e) => {
379                    let error_str = e.to_string();
380
381                    // Check if retryable
382                    if !RetryPolicy::is_retryable(&error_str) {
383                        debug!(
384                            error = %error_str,
385                            "error is not retryable, failing immediately"
386                        );
387                        return Err(e);
388                    }
389
390                    // Last attempt?
391                    if attempt >= policy.max_attempts {
392                        warn!(
393                            attempt = attempt,
394                            max_attempts = policy.max_attempts,
395                            error = %error_str,
396                            "all retry attempts exhausted"
397                        );
398                        return Err(e);
399                    }
400
401                    // Call retry callback
402                    if let Some(ref callback) = policy.on_retry {
403                        callback(attempt, &error_str);
404                    }
405
406                    // Calculate and apply delay
407                    let delay = policy.calculate_delay(attempt);
408
409                    #[allow(clippy::cast_possible_truncation)]
410                    #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
411                    let delay_ms = delay.as_millis() as u64;
412                    warn!(
413                        attempt = attempt,
414                        max_attempts = policy.max_attempts,
415                        error = %error_str,
416                        delay_ms = delay_ms,
417                        "command failed, will retry after delay"
418                    );
419
420                    if self.debug_config.verbose {
421                        eprintln!("[VERBOSE] Waiting {delay:?} before retry");
422                    }
423                    sleep(delay).await;
424
425                    last_error = Some(e);
426                }
427            }
428        }
429
430        Err(last_error.unwrap_or_else(|| crate::error::Error::custom("Retry failed")))
431    }
432
433    /// Get command history
434    #[must_use]
435    pub fn get_command_log(&self) -> Vec<String> {
436        self.debug_config.get_command_log()
437    }
438
439    /// Clear command history
440    pub fn clear_log(&self) {
441        self.debug_config.clear_log();
442    }
443}
444
445impl Default for DebugExecutor {
446    fn default() -> Self {
447        Self::new()
448    }
449}
450
451/// Helper for previewing commands without executing them
452pub struct DryRunPreview {
453    /// Commands that would be executed
454    pub commands: Vec<String>,
455}
456
457impl DryRunPreview {
458    /// Create a preview of commands
459    #[must_use]
460    pub fn new(commands: Vec<String>) -> Self {
461        Self { commands }
462    }
463
464    /// Print the preview
465    pub fn print(&self) {
466        if self.commands.is_empty() {
467            println!("No commands would be executed.");
468            return;
469        }
470
471        println!("Would execute the following commands:");
472        for (i, cmd) in self.commands.iter().enumerate() {
473            println!("  {}. {}", i + 1, cmd);
474        }
475    }
476}
477
478impl std::fmt::Display for DryRunPreview {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        if self.commands.is_empty() {
481            return write!(f, "No commands would be executed.");
482        }
483
484        writeln!(f, "Would execute the following commands:")?;
485        for (i, cmd) in self.commands.iter().enumerate() {
486            writeln!(f, "  {}. {}", i + 1, cmd)?;
487        }
488        Ok(())
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_debug_config() {
498        let config = DebugConfig::new()
499            .dry_run(true)
500            .verbose(true)
501            .dry_run_prefix("[TEST]");
502
503        assert!(config.dry_run);
504        assert!(config.verbose);
505        assert_eq!(config.dry_run_prefix, "[TEST]");
506    }
507
508    #[test]
509    fn test_retry_policy_delay() {
510        // Fixed backoff
511        let policy = RetryPolicy::new().backoff(BackoffStrategy::Fixed(Duration::from_millis(100)));
512        assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
513        assert_eq!(policy.calculate_delay(3), Duration::from_millis(100));
514
515        // Linear backoff
516        let policy = RetryPolicy::new().backoff(BackoffStrategy::Linear {
517            initial: Duration::from_millis(100),
518            increment: Duration::from_millis(50),
519        });
520        assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
521        assert_eq!(policy.calculate_delay(2), Duration::from_millis(150));
522        assert_eq!(policy.calculate_delay(3), Duration::from_millis(200));
523
524        // Exponential backoff
525        let policy = RetryPolicy::new().backoff(BackoffStrategy::Exponential {
526            initial: Duration::from_millis(100),
527            max: Duration::from_secs(1),
528            multiplier: 2.0,
529        });
530        assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
531        assert_eq!(policy.calculate_delay(2), Duration::from_millis(200));
532        assert_eq!(policy.calculate_delay(3), Duration::from_millis(400));
533        assert_eq!(policy.calculate_delay(5), Duration::from_secs(1)); // Capped at max
534    }
535
536    #[test]
537    fn test_retryable_errors() {
538        assert!(RetryPolicy::is_retryable("connection refused"));
539        assert!(RetryPolicy::is_retryable("operation timeout"));
540        assert!(RetryPolicy::is_retryable(
541            "Cannot connect to the Docker daemon"
542        ));
543        assert!(!RetryPolicy::is_retryable("image not found"));
544        assert!(!RetryPolicy::is_retryable("permission denied"));
545    }
546
547    #[test]
548    fn test_command_logging() {
549        let config = DebugConfig::new();
550        config.log_command("docker ps -a");
551        config.log_command("docker run nginx");
552
553        let log = config.get_command_log();
554        assert_eq!(log.len(), 2);
555        assert_eq!(log[0], "docker ps -a");
556        assert_eq!(log[1], "docker run nginx");
557
558        config.clear_log();
559        assert_eq!(config.get_command_log().len(), 0);
560    }
561
562    #[test]
563    fn test_dry_run_preview() {
564        let commands = vec![
565            "docker pull nginx".to_string(),
566            "docker run -d nginx".to_string(),
567        ];
568
569        let preview = DryRunPreview::new(commands);
570        let output = preview.to_string();
571
572        assert!(output.contains("Would execute"));
573        assert!(output.contains("1. docker pull nginx"));
574        assert!(output.contains("2. docker run -d nginx"));
575    }
576}