diff --git a/README.md b/README.md index e5b39ab..da17a8c 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ wrkflw run --emulate .github/workflows/ci.yml # Run with verbose output wrkflw run --verbose .github/workflows/ci.yml + +# Preserve failed containers for debugging +wrkflw run --preserve-containers-on-failure .github/workflows/ci.yml ``` ### Using the TUI Interface @@ -223,6 +226,23 @@ WRKFLW supports composite actions, which are actions made up of multiple steps. WRKFLW automatically cleans up any Docker containers created during workflow execution, even if the process is interrupted with Ctrl+C. +For debugging failed workflows, you can preserve containers that fail by using the `--preserve-containers-on-failure` flag: + +```bash +# Preserve failed containers for debugging +wrkflw run --preserve-containers-on-failure .github/workflows/build.yml + +# Also available in TUI mode +wrkflw tui --preserve-containers-on-failure +``` + +When a container fails with this flag enabled, WRKFLW will: +- Keep the failed container running instead of removing it +- Log the container ID and provide inspection instructions +- Show a message like: `Preserving container abc123 for debugging (exit code: 1). Use 'docker exec -it abc123 bash' to inspect.` + +This allows you to inspect the exact state of the container when the failure occurred, examine files, check environment variables, and debug issues more effectively. + ## Limitations ### Supported Features diff --git a/crates/executor/src/docker.rs b/crates/executor/src/docker.rs index 730d5fa..dec87a6 100644 --- a/crates/executor/src/docker.rs +++ b/crates/executor/src/docker.rs @@ -24,15 +24,23 @@ static CUSTOMIZED_IMAGES: Lazy>> = pub struct DockerRuntime { docker: Docker, + preserve_containers_on_failure: bool, } impl DockerRuntime { pub fn new() -> Result { + Self::new_with_config(false) + } + + pub fn new_with_config(preserve_containers_on_failure: bool) -> Result { let docker = Docker::connect_with_local_defaults().map_err(|e| { ContainerError::ContainerStart(format!("Failed to connect to Docker: {}", e)) })?; - Ok(DockerRuntime { docker }) + Ok(DockerRuntime { + docker, + preserve_containers_on_failure, + }) } // Add a method to store and retrieve customized images (e.g., with Python installed) @@ -998,13 +1006,23 @@ impl DockerRuntime { logging::warning("Retrieving container logs timed out"); } - // Clean up container with a timeout - let _ = tokio::time::timeout( - std::time::Duration::from_secs(10), - self.docker.remove_container(&container.id, None), - ) - .await; - untrack_container(&container.id); + // Clean up container with a timeout, but preserve on failure if configured + if exit_code == 0 || !self.preserve_containers_on_failure { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + self.docker.remove_container(&container.id, None), + ) + .await; + untrack_container(&container.id); + } else { + // Container failed and we want to preserve it for debugging + logging::info(&format!( + "Preserving container {} for debugging (exit code: {}). Use 'docker exec -it {} bash' to inspect.", + container.id, exit_code, container.id + )); + // Still untrack it from the automatic cleanup system to prevent it from being cleaned up later + untrack_container(&container.id); + } // Log detailed information about the command execution for debugging if exit_code != 0 { diff --git a/crates/executor/src/engine.rs b/crates/executor/src/engine.rs index d752128..2f8bbdc 100644 --- a/crates/executor/src/engine.rs +++ b/crates/executor/src/engine.rs @@ -24,19 +24,18 @@ use runtime::emulation; /// Execute a GitHub Actions workflow file locally pub async fn execute_workflow( workflow_path: &Path, - runtime_type: RuntimeType, - verbose: bool, + config: ExecutionConfig, ) -> Result { logging::info(&format!("Executing workflow: {}", workflow_path.display())); - logging::info(&format!("Runtime: {:?}", runtime_type)); + logging::info(&format!("Runtime: {:?}", config.runtime_type)); // Determine if this is a GitLab CI/CD pipeline or GitHub Actions workflow let is_gitlab = is_gitlab_pipeline(workflow_path); if is_gitlab { - execute_gitlab_pipeline(workflow_path, runtime_type, verbose).await + execute_gitlab_pipeline(workflow_path, config.clone()).await } else { - execute_github_workflow(workflow_path, runtime_type, verbose).await + execute_github_workflow(workflow_path, config.clone()).await } } @@ -72,8 +71,7 @@ fn is_gitlab_pipeline(path: &Path) -> bool { /// Execute a GitHub Actions workflow file locally async fn execute_github_workflow( workflow_path: &Path, - runtime_type: RuntimeType, - verbose: bool, + config: ExecutionConfig, ) -> Result { // 1. Parse workflow file let workflow = parse_workflow(workflow_path)?; @@ -82,7 +80,10 @@ async fn execute_github_workflow( let execution_plan = dependency::resolve_dependencies(&workflow)?; // 3. Initialize appropriate runtime - let runtime = initialize_runtime(runtime_type.clone())?; + let runtime = initialize_runtime( + config.runtime_type.clone(), + config.preserve_containers_on_failure, + )?; // Create a temporary workspace directory let workspace_dir = tempfile::tempdir() @@ -94,7 +95,7 @@ async fn execute_github_workflow( // Add runtime mode to environment env_context.insert( "WRKFLW_RUNTIME_MODE".to_string(), - if runtime_type == RuntimeType::Emulation { + if config.runtime_type == RuntimeType::Emulation { "emulation".to_string() } else { "docker".to_string() @@ -124,7 +125,7 @@ async fn execute_github_workflow( &workflow, runtime.as_ref(), &env_context, - verbose, + config.verbose, ) .await?; @@ -164,8 +165,7 @@ async fn execute_github_workflow( /// Execute a GitLab CI/CD pipeline locally async fn execute_gitlab_pipeline( pipeline_path: &Path, - runtime_type: RuntimeType, - verbose: bool, + config: ExecutionConfig, ) -> Result { logging::info("Executing GitLab CI/CD pipeline"); @@ -180,7 +180,10 @@ async fn execute_gitlab_pipeline( let execution_plan = resolve_gitlab_dependencies(&pipeline, &workflow)?; // 4. Initialize appropriate runtime - let runtime = initialize_runtime(runtime_type.clone())?; + let runtime = initialize_runtime( + config.runtime_type.clone(), + config.preserve_containers_on_failure, + )?; // Create a temporary workspace directory let workspace_dir = tempfile::tempdir() @@ -192,7 +195,7 @@ async fn execute_gitlab_pipeline( // Add runtime mode to environment env_context.insert( "WRKFLW_RUNTIME_MODE".to_string(), - if runtime_type == RuntimeType::Emulation { + if config.runtime_type == RuntimeType::Emulation { "emulation".to_string() } else { "docker".to_string() @@ -216,7 +219,7 @@ async fn execute_gitlab_pipeline( &workflow, runtime.as_ref(), &env_context, - verbose, + config.verbose, ) .await?; @@ -356,12 +359,13 @@ fn resolve_gitlab_dependencies( // Determine if Docker is available or fall back to emulation fn initialize_runtime( runtime_type: RuntimeType, + preserve_containers_on_failure: bool, ) -> Result, ExecutionError> { match runtime_type { RuntimeType::Docker => { if docker::is_available() { // Handle the Result returned by DockerRuntime::new() - match docker::DockerRuntime::new() { + match docker::DockerRuntime::new_with_config(preserve_containers_on_failure) { Ok(docker_runtime) => Ok(Box::new(docker_runtime)), Err(e) => { logging::error(&format!( @@ -386,6 +390,13 @@ pub enum RuntimeType { Emulation, } +#[derive(Debug, Clone)] +pub struct ExecutionConfig { + pub runtime_type: RuntimeType, + pub verbose: bool, + pub preserve_containers_on_failure: bool, +} + pub struct ExecutionResult { pub jobs: Vec, pub failure_details: Option, diff --git a/crates/executor/src/lib.rs b/crates/executor/src/lib.rs index 847e21b..b45d2ca 100644 --- a/crates/executor/src/lib.rs +++ b/crates/executor/src/lib.rs @@ -10,4 +10,6 @@ pub mod substitution; // Re-export public items pub use docker::cleanup_resources; -pub use engine::{execute_workflow, JobResult, JobStatus, RuntimeType, StepResult, StepStatus}; +pub use engine::{ + execute_workflow, ExecutionConfig, JobResult, JobStatus, RuntimeType, StepResult, StepStatus, +}; diff --git a/crates/ui/src/app/mod.rs b/crates/ui/src/app/mod.rs index bbcc691..c23908b 100644 --- a/crates/ui/src/app/mod.rs +++ b/crates/ui/src/app/mod.rs @@ -26,6 +26,7 @@ pub async fn run_wrkflw_tui( path: Option<&PathBuf>, runtime_type: RuntimeType, verbose: bool, + preserve_containers_on_failure: bool, ) -> io::Result<()> { // Terminal setup enable_raw_mode()?; @@ -41,7 +42,11 @@ pub async fn run_wrkflw_tui( ) = mpsc::channel(); // Initialize app state - let mut app = App::new(runtime_type.clone(), tx.clone()); + let mut app = App::new( + runtime_type.clone(), + tx.clone(), + preserve_containers_on_failure, + ); if app.validation_mode { app.logs.push("Starting in validation mode".to_string()); diff --git a/crates/ui/src/app/state.rs b/crates/ui/src/app/state.rs index 1a6e50e..596b10d 100644 --- a/crates/ui/src/app/state.rs +++ b/crates/ui/src/app/state.rs @@ -19,6 +19,7 @@ pub struct App { pub show_help: bool, pub runtime_type: RuntimeType, pub validation_mode: bool, + pub preserve_containers_on_failure: bool, pub execution_queue: Vec, // Indices of workflows to execute pub current_execution: Option, pub logs: Vec, // Overall execution logs @@ -42,7 +43,11 @@ pub struct App { } impl App { - pub fn new(runtime_type: RuntimeType, tx: mpsc::Sender) -> App { + pub fn new( + runtime_type: RuntimeType, + tx: mpsc::Sender, + preserve_containers_on_failure: bool, + ) -> App { let mut workflow_list_state = ListState::default(); workflow_list_state.select(Some(0)); @@ -119,6 +124,7 @@ impl App { show_help: false, runtime_type, validation_mode: false, + preserve_containers_on_failure, execution_queue: Vec::new(), current_execution: None, logs: initial_logs, diff --git a/crates/ui/src/handlers/workflow.rs b/crates/ui/src/handlers/workflow.rs index 3a14ed1..79f443f 100644 --- a/crates/ui/src/handlers/workflow.rs +++ b/crates/ui/src/handlers/workflow.rs @@ -127,7 +127,13 @@ pub async fn execute_workflow_cli( verbose )); - match executor::execute_workflow(path, runtime_type, verbose).await { + let config = executor::ExecutionConfig { + runtime_type, + verbose, + preserve_containers_on_failure: false, // Default for this path + }; + + match executor::execute_workflow(path, config).await { Ok(result) => { println!("\nWorkflow execution results:"); @@ -415,6 +421,7 @@ pub fn start_next_workflow_execution( }; let validation_mode = app.validation_mode; + let preserve_containers_on_failure = app.preserve_containers_on_failure; // Update workflow status and add execution details app.workflows[next_idx].status = WorkflowStatus::Running; @@ -483,9 +490,15 @@ pub fn start_next_workflow_execution( } } else { // Use safe FD redirection for execution + let config = executor::ExecutionConfig { + runtime_type, + verbose, + preserve_containers_on_failure, + }; + let execution_result = utils::fd::with_stderr_to_null(|| { futures::executor::block_on(async { - executor::execute_workflow(&workflow_path, runtime_type, verbose).await + executor::execute_workflow(&workflow_path, config).await }) }) .map_err(|e| format!("Failed to redirect stderr during execution: {}", e))?; diff --git a/crates/wrkflw/src/main.rs b/crates/wrkflw/src/main.rs index b9b4a90..dbbe312 100644 --- a/crates/wrkflw/src/main.rs +++ b/crates/wrkflw/src/main.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; name = "wrkflw", about = "GitHub & GitLab CI/CD validator and executor", version, - long_about = "A CI/CD validator and executor that runs workflows locally.\n\nExamples:\n wrkflw validate # Validate all workflows in .github/workflows\n wrkflw run .github/workflows/build.yml # Run a specific workflow\n wrkflw run .gitlab-ci.yml # Run a GitLab CI pipeline\n wrkflw --verbose run .github/workflows/build.yml # Run with more output\n wrkflw --debug run .github/workflows/build.yml # Run with detailed debug information\n wrkflw run --emulate .github/workflows/build.yml # Use emulation mode instead of Docker" + long_about = "A CI/CD validator and executor that runs workflows locally.\n\nExamples:\n wrkflw validate # Validate all workflows in .github/workflows\n wrkflw run .github/workflows/build.yml # Run a specific workflow\n wrkflw run .gitlab-ci.yml # Run a GitLab CI pipeline\n wrkflw --verbose run .github/workflows/build.yml # Run with more output\n wrkflw --debug run .github/workflows/build.yml # Run with detailed debug information\n wrkflw run --emulate .github/workflows/build.yml # Use emulation mode instead of Docker\n wrkflw run --preserve-containers-on-failure .github/workflows/build.yml # Keep failed containers for debugging" )] struct Wrkflw { #[command(subcommand)] @@ -49,6 +49,10 @@ enum Commands { #[arg(long, default_value_t = false)] show_action_messages: bool, + /// Preserve Docker containers on failure for debugging (Docker mode only) + #[arg(long)] + preserve_containers_on_failure: bool, + /// Explicitly run as GitLab CI/CD pipeline #[arg(long)] gitlab: bool, @@ -66,6 +70,10 @@ enum Commands { /// Show 'Would execute GitHub action' messages in emulation mode #[arg(long, default_value_t = false)] show_action_messages: bool, + + /// Preserve Docker containers on failure for debugging (Docker mode only) + #[arg(long)] + preserve_containers_on_failure: bool, }, /// Trigger a GitHub workflow remotely @@ -305,13 +313,18 @@ async fn main() { path, emulate, show_action_messages: _, + preserve_containers_on_failure, gitlab, }) => { - // Determine the runtime type - let runtime_type = if *emulate { - executor::RuntimeType::Emulation - } else { - executor::RuntimeType::Docker + // Create execution configuration + let config = executor::ExecutionConfig { + runtime_type: if *emulate { + executor::RuntimeType::Emulation + } else { + executor::RuntimeType::Docker + }, + verbose, + preserve_containers_on_failure: *preserve_containers_on_failure, }; // Check if we're explicitly or implicitly running a GitLab pipeline @@ -325,7 +338,7 @@ async fn main() { logging::info(&format!("Running {} at: {}", workflow_type, path.display())); // Execute the workflow - let result = executor::execute_workflow(path, runtime_type, verbose) + let result = executor::execute_workflow(path, config) .await .unwrap_or_else(|e| { eprintln!("Error executing workflow: {}", e); @@ -439,6 +452,7 @@ async fn main() { path, emulate, show_action_messages: _, + preserve_containers_on_failure, }) => { // Set runtime type based on the emulate flag let runtime_type = if *emulate { @@ -448,7 +462,14 @@ async fn main() { }; // Call the TUI implementation from the ui crate - if let Err(e) = ui::run_wrkflw_tui(path.as_ref(), runtime_type, verbose).await { + if let Err(e) = ui::run_wrkflw_tui( + path.as_ref(), + runtime_type, + verbose, + *preserve_containers_on_failure, + ) + .await + { eprintln!("Error running TUI: {}", e); std::process::exit(1); } @@ -477,7 +498,7 @@ async fn main() { let runtime_type = executor::RuntimeType::Docker; // Call the TUI implementation from the ui crate with default path - if let Err(e) = ui::run_wrkflw_tui(None, runtime_type, verbose).await { + if let Err(e) = ui::run_wrkflw_tui(None, runtime_type, verbose, false).await { eprintln!("Error running TUI: {}", e); std::process::exit(1); }