feat: add --preserve-containers-on-failure flag for debugging

feat: add --preserve-containers-on-failure flag for debugging
This commit is contained in:
Gokul
2025-08-09 13:22:50 +05:30
committed by GitHub
8 changed files with 134 additions and 38 deletions

View File

@@ -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

View File

@@ -24,15 +24,23 @@ static CUSTOMIZED_IMAGES: Lazy<Mutex<HashMap<String, String>>> =
pub struct DockerRuntime {
docker: Docker,
preserve_containers_on_failure: bool,
}
impl DockerRuntime {
pub fn new() -> Result<Self, ContainerError> {
Self::new_with_config(false)
}
pub fn new_with_config(preserve_containers_on_failure: bool) -> Result<Self, ContainerError> {
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 {

View File

@@ -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<ExecutionResult, ExecutionError> {
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<ExecutionResult, ExecutionError> {
// 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<ExecutionResult, ExecutionError> {
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<Box<dyn ContainerRuntime>, 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<JobResult>,
pub failure_details: Option<String>,

View File

@@ -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,
};

View File

@@ -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());

View File

@@ -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<usize>, // Indices of workflows to execute
pub current_execution: Option<usize>,
pub logs: Vec<String>, // Overall execution logs
@@ -42,7 +43,11 @@ pub struct App {
}
impl App {
pub fn new(runtime_type: RuntimeType, tx: mpsc::Sender<ExecutionResultMsg>) -> App {
pub fn new(
runtime_type: RuntimeType,
tx: mpsc::Sender<ExecutionResultMsg>,
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,

View File

@@ -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))?;

View File

@@ -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);
}