use crate::container::{
rebase_working_dir_or_error, ContainerError, ContainerOutput, ContainerRuntime,
};
use async_trait::async_trait;
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use tempfile::TempDir;
use which;
use wrkflw_logging;
use ignore::{gitignore::GitignoreBuilder, Match};
static EMULATION_WORKSPACES: Lazy<Mutex<Vec<PathBuf>>> = Lazy::new(|| Mutex::new(Vec::new()));
static EMULATION_PROCESSES: Lazy<Mutex<Vec<u32>>> = Lazy::new(|| Mutex::new(Vec::new()));
pub struct EmulationRuntime {
#[allow(dead_code)]
workspace: TempDir,
}
impl Default for EmulationRuntime {
fn default() -> Self {
Self::new()
}
}
impl EmulationRuntime {
pub fn new() -> Self {
let workspace =
tempfile::tempdir().expect("Failed to create temporary workspace for emulation");
if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
workspaces.push(workspace.path().to_path_buf());
}
EmulationRuntime { workspace }
}
#[allow(dead_code)]
fn prepare_workspace(&self, _working_dir: &Path, volumes: &[(&Path, &Path)]) -> PathBuf {
let container_root = self.workspace.path().to_path_buf();
let github_workspace = container_root.join("github").join("workspace");
fs::create_dir_all(&github_workspace)
.expect("Failed to create github/workspace directory structure");
for (host_path, container_path) in volumes {
let target_path = if container_path.starts_with("/github/workspace") {
let rel_path = container_path
.strip_prefix("/github/workspace")
.unwrap_or(Path::new(""));
github_workspace.join(rel_path)
} else if container_path.starts_with("/") {
container_root.join(container_path.strip_prefix("/").unwrap_or(container_path))
} else {
container_root.join(container_path)
};
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).expect("Failed to create directory structure");
}
if host_path.is_dir() {
if *container_path == Path::new("/github/workspace") {
copy_directory_contents(host_path, &github_workspace)
.expect("Failed to copy project files to workspace");
} else {
fs::create_dir_all(&target_path).expect("Failed to create target directory");
for entry in fs::read_dir(host_path)
.expect("Failed to read source directory")
.flatten()
{
let source = entry.path();
let file_name = match source.file_name() {
Some(name) => name,
None => {
eprintln!(
"Warning: Could not get file name from path: {:?}",
source
);
continue; }
};
let dest = target_path.join(file_name);
if source.is_file() {
if let Err(e) = fs::copy(&source, &dest) {
eprintln!(
"Warning: Failed to copy file from {:?} to {:?}: {}",
&source, &dest, e
);
}
} else {
fs::create_dir_all(&dest).expect("Failed to create subdirectory");
}
}
}
} else if host_path.is_file() {
let file_name = match host_path.file_name() {
Some(name) => name,
None => {
eprintln!(
"Warning: Could not get file name from path: {:?}",
host_path
);
continue; }
};
let dest = target_path.join(file_name);
if let Err(e) = fs::copy(host_path, &dest) {
eprintln!(
"Warning: Failed to copy file from {:?} to {:?}: {}",
host_path, &dest, e
);
}
}
}
github_workspace
}
}
#[async_trait]
impl ContainerRuntime for EmulationRuntime {
async fn run_container(
&self,
_image: &str,
command: &[&str],
env_vars: &[(&str, &str)],
working_dir: &Path,
volumes: &[(&Path, &Path)],
_entrypoint: Option<&str>,
) -> Result<ContainerOutput, ContainerError> {
let mut command_str = String::new();
for part in command {
if !command_str.is_empty() {
command_str.push(' ');
}
command_str.push_str(part);
}
wrkflw_logging::info(&format!("Executing command in container: {}", command_str));
wrkflw_logging::info(&format!("Working directory: {}", working_dir.display()));
wrkflw_logging::info(&format!("Command length: {}", command.len()));
if command.is_empty() {
wrkflw_logging::warning(
"Emulation mode cannot run Docker actions with native entrypoints. \
Use --runtime docker for full Docker action support.",
);
return Ok(ContainerOutput {
stdout: String::new(),
stderr: "Emulation mode: skipped Docker action (requires --runtime docker). \
The action was NOT executed."
.to_string(),
exit_code: 1,
});
}
for (i, part) in command.iter().enumerate() {
wrkflw_logging::info(&format!("Command part {}: '{}'", i, part));
}
wrkflw_logging::info("Environment variables:");
for (key, value) in env_vars {
wrkflw_logging::info(&format!(" {}={}", key, value));
}
let actual_working_dir = rebase_working_dir_or_error(working_dir, volumes, "emulation")?;
wrkflw_logging::info(&format!(
"Using actual working directory: {}",
actual_working_dir.display()
));
let command_path = which::which(command[0]);
match &command_path {
Ok(path) => wrkflw_logging::info(&format!("Found command at: {}", path.display())),
Err(e) => wrkflw_logging::error(&format!(
"Command not found in PATH: {} - Error: {}",
command[0], e
)),
}
let cmd_basename = Path::new(command[0])
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(command[0]);
let use_direct_exec = matches!(
cmd_basename,
"bash" | "sh" | "python" | "python3" | "pwsh" | "powershell" | "cargo" | "rustup"
);
let mut cmd;
if use_direct_exec {
cmd = Command::new(command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
} else {
cmd = Command::new("sh");
cmd.arg("-c");
cmd.arg(&command_str);
}
let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if matches!(cmd_basename, "cargo" | "rustup") {
wrkflw_logging::info(&format!(
"Using project directory for Rust command: {}",
project_dir.display()
));
cmd.current_dir(&project_dir);
} else {
cmd.current_dir(&actual_working_dir);
}
let project_dir_str = project_dir.to_string_lossy();
for (key, value) in env_vars {
if value.contains("${CI_PROJECT_DIR}") {
cmd.env(key, value.replace("${CI_PROJECT_DIR}", &project_dir_str));
} else {
cmd.env(key, value);
}
}
match cmd.output() {
Ok(output_result) => {
let exit_code = output_result.status.code().unwrap_or(-1);
let output = String::from_utf8_lossy(&output_result.stdout).to_string();
let error = String::from_utf8_lossy(&output_result.stderr).to_string();
wrkflw_logging::debug(&format!("Command completed with exit code: {}", exit_code));
Ok(ContainerOutput {
stdout: output,
stderr: error,
exit_code,
})
}
Err(e) => {
return Err(ContainerError::ContainerExecution(format!(
"Failed to execute command: {}\nError: {}",
command_str, e
)));
}
}
}
async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
wrkflw_logging::info(&format!("🔄 Emulation: Pretending to pull image {}", image));
Ok(())
}
async fn build_image(
&self,
dockerfile: &Path,
tag: &str,
_context_dir: &Path,
) -> Result<(), ContainerError> {
wrkflw_logging::info(&format!(
"🔄 Emulation: Pretending to build image {} from {}",
tag,
dockerfile.display()
));
Ok(())
}
async fn image_exists(&self, _tag: &str) -> Result<bool, ContainerError> {
Ok(false)
}
async fn prepare_language_environment(
&self,
language: &str,
version: Option<&str>,
_additional_packages: Option<Vec<String>>,
) -> Result<String, ContainerError> {
let base_image = match language {
"python" => version.map_or("python:3.11-slim".to_string(), |v| format!("python:{}", v)),
"node" => version.map_or("node:20-slim".to_string(), |v| format!("node:{}", v)),
"java" => version.map_or("eclipse-temurin:17-jdk".to_string(), |v| {
format!("eclipse-temurin:{}", v)
}),
"go" => version.map_or("golang:1.21-slim".to_string(), |v| format!("golang:{}", v)),
"dotnet" => version.map_or("mcr.microsoft.com/dotnet/sdk:7.0".to_string(), |v| {
format!("mcr.microsoft.com/dotnet/sdk:{}", v)
}),
"rust" => version.map_or("rust:latest".to_string(), |v| format!("rust:{}", v)),
_ => {
return Err(ContainerError::ContainerStart(format!(
"Unsupported language: {}",
language
)))
}
};
Ok(base_image)
}
}
#[allow(dead_code)]
fn create_gitignore_matcher(
dir: &Path,
) -> Result<Option<ignore::gitignore::Gitignore>, std::io::Error> {
let mut builder = GitignoreBuilder::new(dir);
let gitignore_path = dir.join(".gitignore");
if gitignore_path.exists() {
builder.add(&gitignore_path);
}
if let Err(e) = builder.add_line(None, "target/") {
wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
}
if let Err(e) = builder.add_line(None, ".git/") {
wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
}
match builder.build() {
Ok(gitignore) => Ok(Some(gitignore)),
Err(e) => {
wrkflw_logging::warning(&format!("Failed to build gitignore matcher: {}", e));
Ok(None)
}
}
}
fn copy_directory_contents(source: &Path, dest: &Path) -> std::io::Result<()> {
copy_directory_contents_with_gitignore(source, dest, None)
}
fn copy_directory_contents_with_gitignore(
source: &Path,
dest: &Path,
gitignore: Option<&ignore::gitignore::Gitignore>,
) -> std::io::Result<()> {
fs::create_dir_all(dest)?;
let root_gitignore;
let gitignore = if gitignore.is_none() {
root_gitignore = create_gitignore_matcher(source)?;
root_gitignore.as_ref()
} else {
gitignore
};
for entry in fs::read_dir(source)? {
let entry = entry?;
let path = entry.path();
if let Some(gitignore) = gitignore {
let relative_path = path.strip_prefix(source).unwrap_or(&path);
match gitignore.matched(relative_path, path.is_dir()) {
Match::Ignore(_) => {
wrkflw_logging::debug(&format!("Skipping ignored file/directory: {path:?}"));
continue;
}
Match::Whitelist(_) | Match::None => {
}
}
}
let file_name = match path.file_name() {
Some(name) => name,
None => {
eprintln!("Warning: Could not get file name from path: {:?}", path);
continue; }
};
let dest_path = dest.join(file_name);
let file_name_str = file_name.to_string_lossy();
if file_name_str.starts_with(".")
&& file_name_str != ".gitignore"
&& file_name_str != ".github"
&& !file_name_str.starts_with(".env")
{
continue;
}
if path.is_dir() {
copy_directory_contents_with_gitignore(&path, &dest_path, gitignore)?;
} else {
fs::copy(&path, &dest_path)?;
}
}
Ok(())
}
pub async fn handle_special_action(action: &str) -> Result<(), ContainerError> {
let action_parts: Vec<&str> = action.split('@').collect();
let action_name = action_parts[0];
let action_version = if action_parts.len() > 1 {
action_parts[1]
} else {
"latest"
};
wrkflw_logging::info(&format!(
"🔄 Processing action: {} @ {}",
action_name, action_version
));
if action.starts_with("cachix/install-nix-action") {
wrkflw_logging::info("🔄 Emulating cachix/install-nix-action");
let nix_installed = Command::new("which")
.arg("nix")
.output()
.map(|output| output.status.success())
.unwrap_or(false);
if !nix_installed {
wrkflw_logging::info("🔄 Emulation: Nix is required but not installed.");
wrkflw_logging::info(
"🔄 To use this workflow, please install Nix: https://nixos.org/download.html",
);
wrkflw_logging::info("🔄 Continuing emulation, but nix commands will fail.");
} else {
wrkflw_logging::info("🔄 Emulation: Using system-installed Nix");
}
} else if action.starts_with("actions-rs/cargo@") {
wrkflw_logging::info(&format!("🔄 Detected Rust cargo action: {}", action));
check_command_available("cargo", "Rust/Cargo", "https://rustup.rs/");
} else if action.starts_with("actions-rs/toolchain@") {
wrkflw_logging::info(&format!("🔄 Detected Rust toolchain action: {}", action));
check_command_available("rustc", "Rust", "https://rustup.rs/");
} else if action.starts_with("actions-rs/fmt@") {
wrkflw_logging::info(&format!("🔄 Detected Rust formatter action: {}", action));
check_command_available("rustfmt", "rustfmt", "rustup component add rustfmt");
} else if action.starts_with("dtolnay/rust-toolchain@") {
wrkflw_logging::info(&format!(
"🔄 Detected dtolnay Rust toolchain action: {}",
action
));
check_command_available("rustc", "Rust", "https://rustup.rs/");
check_command_available("cargo", "Cargo", "https://rustup.rs/");
} else if action.starts_with("actions/setup-node@") {
wrkflw_logging::info(&format!("🔄 Detected Node.js setup action: {}", action));
check_command_available("node", "Node.js", "https://nodejs.org/");
} else if action.starts_with("actions/setup-python@") {
wrkflw_logging::info(&format!("🔄 Detected Python setup action: {}", action));
check_command_available("python", "Python", "https://www.python.org/downloads/");
} else if action.starts_with("actions/setup-java@") {
wrkflw_logging::info(&format!("🔄 Detected Java setup action: {}", action));
check_command_available("java", "Java", "https://adoptium.net/");
} else if action.starts_with("actions/checkout@") {
wrkflw_logging::info("🔄 Detected checkout action - workspace files are already prepared");
} else if action.starts_with("actions/cache@") {
wrkflw_logging::info("🔄 Detected cache action - local cache emulation active");
} else {
wrkflw_logging::info(&format!(
"🔄 Action '{}' has no special handling in emulation mode",
action_name
));
}
Ok(())
}
fn check_command_available(command: &str, name: &str, install_url: &str) {
let is_available = Command::new("which")
.arg(command)
.output()
.map(|output| output.status.success())
.unwrap_or(false);
if !is_available {
wrkflw_logging::warning(&format!("{} is required but not found on the system", name));
wrkflw_logging::info(&format!(
"To use this action, please install {}: {}",
name, install_url
));
wrkflw_logging::info(&format!(
"Continuing emulation, but {} commands will fail",
name
));
} else {
if let Ok(output) = Command::new(command).arg("--version").output() {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout);
wrkflw_logging::info(&format!("🔄 Using system {}: {}", name, version.trim()));
}
}
}
}
#[allow(dead_code)]
fn add_action_env_vars(
env_map: &mut HashMap<String, String>,
action: &str,
with_params: &Option<HashMap<String, String>>,
) {
if let Some(params) = with_params {
if action.starts_with("actions/setup-node") {
if let Some(version) = params.get("node-version") {
env_map.insert("NODE_VERSION".to_string(), version.clone());
}
env_map.insert(
"NPM_CONFIG_PREFIX".to_string(),
"/tmp/.npm-global".to_string(),
);
env_map.insert("PATH".to_string(), "/tmp/.npm-global/bin:$PATH".to_string());
} else if action.starts_with("actions/setup-python") {
if let Some(version) = params.get("python-version") {
env_map.insert("PYTHON_VERSION".to_string(), version.clone());
}
env_map.insert("PIP_CACHE_DIR".to_string(), "/tmp/.pip-cache".to_string());
} else if action.starts_with("actions/setup-java") {
if let Some(version) = params.get("java-version") {
env_map.insert("JAVA_VERSION".to_string(), version.clone());
}
env_map.insert(
"JAVA_HOME".to_string(),
"/usr/lib/jvm/default-java".to_string(),
);
}
}
}
pub async fn cleanup_resources() {
cleanup_processes().await;
cleanup_workspaces().await;
}
async fn cleanup_processes() {
let processes_to_cleanup = {
if let Ok(processes) = EMULATION_PROCESSES.lock() {
processes.clone()
} else {
vec![]
}
};
for pid in processes_to_cleanup {
wrkflw_logging::info(&format!("Cleaning up emulated process: {}", pid));
#[cfg(unix)]
{
let _ = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.output();
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.arg("/F")
.arg("/PID")
.arg(pid.to_string())
.output();
}
if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
processes.retain(|p| *p != pid);
}
}
}
async fn cleanup_workspaces() {
let workspaces_to_cleanup = {
if let Ok(workspaces) = EMULATION_WORKSPACES.lock() {
workspaces.clone()
} else {
vec![]
}
};
for workspace_path in workspaces_to_cleanup {
wrkflw_logging::info(&format!(
"Cleaning up emulation workspace: {}",
workspace_path.display()
));
if workspace_path.exists() {
match fs::remove_dir_all(&workspace_path) {
Ok(_) => wrkflw_logging::info("Successfully removed workspace directory"),
Err(e) => wrkflw_logging::error(&format!("Error removing workspace: {}", e)),
}
}
if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
workspaces.retain(|w| *w != workspace_path);
}
}
}
#[allow(dead_code)]
pub fn track_process(pid: u32) {
if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
processes.push(pid);
}
}
#[allow(dead_code)]
pub fn untrack_process(pid: u32) {
if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
processes.retain(|p| *p != pid);
}
}
#[allow(dead_code)]
pub fn track_workspace(path: &Path) {
if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
workspaces.push(path.to_path_buf());
}
}
#[allow(dead_code)]
pub fn untrack_workspace(path: &Path) {
if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
workspaces.retain(|w| *w != path);
}
}
#[cfg(test)]
pub fn get_tracked_workspaces() -> Vec<PathBuf> {
if let Ok(workspaces) = EMULATION_WORKSPACES.lock() {
workspaces.clone()
} else {
vec![]
}
}
#[cfg(test)]
pub fn get_tracked_processes() -> Vec<u32> {
if let Ok(processes) = EMULATION_PROCESSES.lock() {
processes.clone()
} else {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_nonzero_exit_code_returns_ok() {
let runtime = EmulationRuntime::new();
let result = runtime
.run_container(
"alpine:latest",
&["exit", "42"],
&[],
Path::new("."),
&[(Path::new("."), Path::new("/github/workspace"))],
None,
)
.await;
let output = result.expect("non-zero exit should return Ok, not Err");
assert_eq!(output.exit_code, 42);
}
#[tokio::test]
async fn test_empty_cmd_returns_failure_in_emulation() {
let runtime = EmulationRuntime::new();
let result = runtime
.run_container(
"some-action:latest",
&[],
&[],
Path::new("."),
&[(Path::new("."), Path::new("/github/workspace"))],
None,
)
.await;
let output = result.expect("empty cmd should return Ok with exit_code 1, not Err");
assert_eq!(output.exit_code, 1, "empty cmd should signal failure");
assert!(
output.stderr.contains("skipped Docker action"),
"stderr should explain the action was not executed"
);
assert!(output.stdout.is_empty(), "stdout should be empty");
}
#[tokio::test]
async fn emulation_errors_when_volumes_dont_cover_working_dir() {
let runtime = EmulationRuntime::new();
let result = runtime
.run_container(
"alpine:latest",
&["echo", "nope"],
&[],
Path::new("/github/workspace"),
&[],
None,
)
.await;
let err = result.expect_err("should error when no volume covers working_dir");
let msg = err.to_string();
assert!(
msg.contains("not covered by any volume mount"),
"unexpected error: {}",
msg
);
assert!(
msg.contains("emulation:"),
"error should carry runtime label, got: {}",
msg
);
}
}