1use crate::container::{
2 rebase_working_dir_or_error, ContainerError, ContainerOutput, ContainerRuntime,
3};
4use async_trait::async_trait;
5use once_cell::sync::Lazy;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::sync::Mutex;
11use tempfile::TempDir;
12use which;
13use wrkflw_logging;
14
15use ignore::{gitignore::GitignoreBuilder, Match};
16
17static EMULATION_WORKSPACES: Lazy<Mutex<Vec<PathBuf>>> = Lazy::new(|| Mutex::new(Vec::new()));
19static EMULATION_PROCESSES: Lazy<Mutex<Vec<u32>>> = Lazy::new(|| Mutex::new(Vec::new()));
20
21pub struct EmulationRuntime {
22 #[allow(dead_code)]
23 workspace: TempDir,
24}
25
26impl Default for EmulationRuntime {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl EmulationRuntime {
33 pub fn new() -> Self {
34 let workspace =
36 tempfile::tempdir().expect("Failed to create temporary workspace for emulation");
37
38 if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
40 workspaces.push(workspace.path().to_path_buf());
41 }
42
43 EmulationRuntime { workspace }
44 }
45
46 #[allow(dead_code)]
47 fn prepare_workspace(&self, _working_dir: &Path, volumes: &[(&Path, &Path)]) -> PathBuf {
48 let container_root = self.workspace.path().to_path_buf();
50
51 let github_workspace = container_root.join("github").join("workspace");
54 fs::create_dir_all(&github_workspace)
55 .expect("Failed to create github/workspace directory structure");
56
57 for (host_path, container_path) in volumes {
59 let target_path = if container_path.starts_with("/github/workspace") {
61 let rel_path = container_path
63 .strip_prefix("/github/workspace")
64 .unwrap_or(Path::new(""));
65 github_workspace.join(rel_path)
66 } else if container_path.starts_with("/") {
67 container_root.join(container_path.strip_prefix("/").unwrap_or(container_path))
69 } else {
70 container_root.join(container_path)
72 };
73
74 if let Some(parent) = target_path.parent() {
76 fs::create_dir_all(parent).expect("Failed to create directory structure");
77 }
78
79 if host_path.is_dir() {
81 if *container_path == Path::new("/github/workspace") {
84 copy_directory_contents(host_path, &github_workspace)
86 .expect("Failed to copy project files to workspace");
87 } else {
88 fs::create_dir_all(&target_path).expect("Failed to create target directory");
90
91 for entry in fs::read_dir(host_path)
93 .expect("Failed to read source directory")
94 .flatten()
95 {
96 let source = entry.path();
97 let file_name = match source.file_name() {
98 Some(name) => name,
99 None => {
100 eprintln!(
101 "Warning: Could not get file name from path: {:?}",
102 source
103 );
104 continue; }
106 };
107 let dest = target_path.join(file_name);
108
109 if source.is_file() {
110 if let Err(e) = fs::copy(&source, &dest) {
111 eprintln!(
112 "Warning: Failed to copy file from {:?} to {:?}: {}",
113 &source, &dest, e
114 );
115 }
116 } else {
117 fs::create_dir_all(&dest).expect("Failed to create subdirectory");
119 }
120 }
121 }
122 } else if host_path.is_file() {
123 let file_name = match host_path.file_name() {
125 Some(name) => name,
126 None => {
127 eprintln!(
128 "Warning: Could not get file name from path: {:?}",
129 host_path
130 );
131 continue; }
133 };
134 let dest = target_path.join(file_name);
135 if let Err(e) = fs::copy(host_path, &dest) {
136 eprintln!(
137 "Warning: Failed to copy file from {:?} to {:?}: {}",
138 host_path, &dest, e
139 );
140 }
141 }
142 }
143
144 github_workspace
146 }
147}
148
149#[async_trait]
150impl ContainerRuntime for EmulationRuntime {
151 async fn run_container(
152 &self,
153 _image: &str,
154 command: &[&str],
155 env_vars: &[(&str, &str)],
156 working_dir: &Path,
157 volumes: &[(&Path, &Path)],
158 _entrypoint: Option<&str>,
159 ) -> Result<ContainerOutput, ContainerError> {
160 let mut command_str = String::new();
162 for part in command {
163 if !command_str.is_empty() {
164 command_str.push(' ');
165 }
166 command_str.push_str(part);
167 }
168
169 wrkflw_logging::info(&format!("Executing command in container: {}", command_str));
171 wrkflw_logging::info(&format!("Working directory: {}", working_dir.display()));
172 wrkflw_logging::info(&format!("Command length: {}", command.len()));
173
174 if command.is_empty() {
175 wrkflw_logging::warning(
179 "Emulation mode cannot run Docker actions with native entrypoints. \
180 Use --runtime docker for full Docker action support.",
181 );
182 return Ok(ContainerOutput {
183 stdout: String::new(),
184 stderr: "Emulation mode: skipped Docker action (requires --runtime docker). \
185 The action was NOT executed."
186 .to_string(),
187 exit_code: 1,
188 });
189 }
190
191 for (i, part) in command.iter().enumerate() {
193 wrkflw_logging::info(&format!("Command part {}: '{}'", i, part));
194 }
195
196 wrkflw_logging::info("Environment variables:");
198 for (key, value) in env_vars {
199 wrkflw_logging::info(&format!(" {}={}", key, value));
200 }
201
202 let actual_working_dir = rebase_working_dir_or_error(working_dir, volumes, "emulation")?;
209
210 wrkflw_logging::info(&format!(
211 "Using actual working directory: {}",
212 actual_working_dir.display()
213 ));
214
215 let command_path = which::which(command[0]);
217 match &command_path {
218 Ok(path) => wrkflw_logging::info(&format!("Found command at: {}", path.display())),
219 Err(e) => wrkflw_logging::error(&format!(
220 "Command not found in PATH: {} - Error: {}",
221 command[0], e
222 )),
223 }
224
225 let cmd_basename = Path::new(command[0])
233 .file_name()
234 .and_then(|s| s.to_str())
235 .unwrap_or(command[0]);
236 let use_direct_exec = matches!(
237 cmd_basename,
238 "bash" | "sh" | "python" | "python3" | "pwsh" | "powershell" | "cargo" | "rustup"
239 );
240
241 let mut cmd;
242 if use_direct_exec {
243 cmd = Command::new(command[0]);
244 if command.len() > 1 {
245 cmd.args(&command[1..]);
246 }
247 } else {
248 cmd = Command::new("sh");
249 cmd.arg("-c");
250 cmd.arg(&command_str);
251 }
252
253 let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
255
256 if matches!(cmd_basename, "cargo" | "rustup") {
258 wrkflw_logging::info(&format!(
259 "Using project directory for Rust command: {}",
260 project_dir.display()
261 ));
262 cmd.current_dir(&project_dir);
263 } else {
264 cmd.current_dir(&actual_working_dir);
265 }
266
267 let project_dir_str = project_dir.to_string_lossy();
270 for (key, value) in env_vars {
271 if value.contains("${CI_PROJECT_DIR}") {
272 cmd.env(key, value.replace("${CI_PROJECT_DIR}", &project_dir_str));
273 } else {
274 cmd.env(key, value);
275 }
276 }
277
278 match cmd.output() {
279 Ok(output_result) => {
280 let exit_code = output_result.status.code().unwrap_or(-1);
281 let output = String::from_utf8_lossy(&output_result.stdout).to_string();
282 let error = String::from_utf8_lossy(&output_result.stderr).to_string();
283
284 wrkflw_logging::debug(&format!("Command completed with exit code: {}", exit_code));
285
286 Ok(ContainerOutput {
287 stdout: output,
288 stderr: error,
289 exit_code,
290 })
291 }
292 Err(e) => {
293 return Err(ContainerError::ContainerExecution(format!(
294 "Failed to execute command: {}\nError: {}",
295 command_str, e
296 )));
297 }
298 }
299 }
300
301 async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
302 wrkflw_logging::info(&format!("🔄 Emulation: Pretending to pull image {}", image));
303 Ok(())
304 }
305
306 async fn build_image(
307 &self,
308 dockerfile: &Path,
309 tag: &str,
310 _context_dir: &Path,
311 ) -> Result<(), ContainerError> {
312 wrkflw_logging::info(&format!(
313 "🔄 Emulation: Pretending to build image {} from {}",
314 tag,
315 dockerfile.display()
316 ));
317 Ok(())
318 }
319
320 async fn image_exists(&self, _tag: &str) -> Result<bool, ContainerError> {
321 Ok(false)
322 }
323
324 async fn prepare_language_environment(
325 &self,
326 language: &str,
327 version: Option<&str>,
328 _additional_packages: Option<Vec<String>>,
329 ) -> Result<String, ContainerError> {
330 let base_image = match language {
333 "python" => version.map_or("python:3.11-slim".to_string(), |v| format!("python:{}", v)),
334 "node" => version.map_or("node:20-slim".to_string(), |v| format!("node:{}", v)),
335 "java" => version.map_or("eclipse-temurin:17-jdk".to_string(), |v| {
336 format!("eclipse-temurin:{}", v)
337 }),
338 "go" => version.map_or("golang:1.21-slim".to_string(), |v| format!("golang:{}", v)),
339 "dotnet" => version.map_or("mcr.microsoft.com/dotnet/sdk:7.0".to_string(), |v| {
340 format!("mcr.microsoft.com/dotnet/sdk:{}", v)
341 }),
342 "rust" => version.map_or("rust:latest".to_string(), |v| format!("rust:{}", v)),
343 _ => {
344 return Err(ContainerError::ContainerStart(format!(
345 "Unsupported language: {}",
346 language
347 )))
348 }
349 };
350
351 Ok(base_image)
354 }
355}
356
357#[allow(dead_code)]
358fn create_gitignore_matcher(
360 dir: &Path,
361) -> Result<Option<ignore::gitignore::Gitignore>, std::io::Error> {
362 let mut builder = GitignoreBuilder::new(dir);
363
364 let gitignore_path = dir.join(".gitignore");
366 if gitignore_path.exists() {
367 builder.add(&gitignore_path);
368 }
369
370 if let Err(e) = builder.add_line(None, "target/") {
372 wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
373 }
374 if let Err(e) = builder.add_line(None, ".git/") {
375 wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
376 }
377
378 match builder.build() {
379 Ok(gitignore) => Ok(Some(gitignore)),
380 Err(e) => {
381 wrkflw_logging::warning(&format!("Failed to build gitignore matcher: {}", e));
382 Ok(None)
383 }
384 }
385}
386
387fn copy_directory_contents(source: &Path, dest: &Path) -> std::io::Result<()> {
388 copy_directory_contents_with_gitignore(source, dest, None)
389}
390
391fn copy_directory_contents_with_gitignore(
392 source: &Path,
393 dest: &Path,
394 gitignore: Option<&ignore::gitignore::Gitignore>,
395) -> std::io::Result<()> {
396 fs::create_dir_all(dest)?;
398
399 let root_gitignore;
401 let gitignore = if gitignore.is_none() {
402 root_gitignore = create_gitignore_matcher(source)?;
403 root_gitignore.as_ref()
404 } else {
405 gitignore
406 };
407
408 for entry in fs::read_dir(source)? {
410 let entry = entry?;
411 let path = entry.path();
412
413 if let Some(gitignore) = gitignore {
415 let relative_path = path.strip_prefix(source).unwrap_or(&path);
416 match gitignore.matched(relative_path, path.is_dir()) {
417 Match::Ignore(_) => {
418 wrkflw_logging::debug(&format!("Skipping ignored file/directory: {path:?}"));
419 continue;
420 }
421 Match::Whitelist(_) | Match::None => {
422 }
424 }
425 }
426
427 let file_name = match path.file_name() {
428 Some(name) => name,
429 None => {
430 eprintln!("Warning: Could not get file name from path: {:?}", path);
431 continue; }
433 };
434 let dest_path = dest.join(file_name);
435
436 let file_name_str = file_name.to_string_lossy();
438 if file_name_str.starts_with(".")
439 && file_name_str != ".gitignore"
440 && file_name_str != ".github"
441 && !file_name_str.starts_with(".env")
442 {
443 continue;
444 }
445
446 if path.is_dir() {
447 copy_directory_contents_with_gitignore(&path, &dest_path, gitignore)?;
449 } else {
450 fs::copy(&path, &dest_path)?;
452 }
453 }
454
455 Ok(())
456}
457
458pub async fn handle_special_action(action: &str) -> Result<(), ContainerError> {
459 let action_parts: Vec<&str> = action.split('@').collect();
461 let action_name = action_parts[0];
462 let action_version = if action_parts.len() > 1 {
463 action_parts[1]
464 } else {
465 "latest"
466 };
467
468 wrkflw_logging::info(&format!(
469 "🔄 Processing action: {} @ {}",
470 action_name, action_version
471 ));
472
473 if action.starts_with("cachix/install-nix-action") {
475 wrkflw_logging::info("🔄 Emulating cachix/install-nix-action");
476
477 let nix_installed = Command::new("which")
479 .arg("nix")
480 .output()
481 .map(|output| output.status.success())
482 .unwrap_or(false);
483
484 if !nix_installed {
485 wrkflw_logging::info("🔄 Emulation: Nix is required but not installed.");
486 wrkflw_logging::info(
487 "🔄 To use this workflow, please install Nix: https://nixos.org/download.html",
488 );
489 wrkflw_logging::info("🔄 Continuing emulation, but nix commands will fail.");
490 } else {
491 wrkflw_logging::info("🔄 Emulation: Using system-installed Nix");
492 }
493 } else if action.starts_with("actions-rs/cargo@") {
494 wrkflw_logging::info(&format!("🔄 Detected Rust cargo action: {}", action));
496
497 check_command_available("cargo", "Rust/Cargo", "https://rustup.rs/");
499 } else if action.starts_with("actions-rs/toolchain@") {
500 wrkflw_logging::info(&format!("🔄 Detected Rust toolchain action: {}", action));
502
503 check_command_available("rustc", "Rust", "https://rustup.rs/");
504 } else if action.starts_with("actions-rs/fmt@") {
505 wrkflw_logging::info(&format!("🔄 Detected Rust formatter action: {}", action));
507
508 check_command_available("rustfmt", "rustfmt", "rustup component add rustfmt");
509 } else if action.starts_with("dtolnay/rust-toolchain@") {
510 wrkflw_logging::info(&format!(
512 "🔄 Detected dtolnay Rust toolchain action: {}",
513 action
514 ));
515
516 check_command_available("rustc", "Rust", "https://rustup.rs/");
517 check_command_available("cargo", "Cargo", "https://rustup.rs/");
518 } else if action.starts_with("actions/setup-node@") {
519 wrkflw_logging::info(&format!("🔄 Detected Node.js setup action: {}", action));
521
522 check_command_available("node", "Node.js", "https://nodejs.org/");
523 } else if action.starts_with("actions/setup-python@") {
524 wrkflw_logging::info(&format!("🔄 Detected Python setup action: {}", action));
526
527 check_command_available("python", "Python", "https://www.python.org/downloads/");
528 } else if action.starts_with("actions/setup-java@") {
529 wrkflw_logging::info(&format!("🔄 Detected Java setup action: {}", action));
531
532 check_command_available("java", "Java", "https://adoptium.net/");
533 } else if action.starts_with("actions/checkout@") {
534 wrkflw_logging::info("🔄 Detected checkout action - workspace files are already prepared");
536 } else if action.starts_with("actions/cache@") {
537 wrkflw_logging::info("🔄 Detected cache action - local cache emulation active");
539 } else {
540 wrkflw_logging::info(&format!(
542 "🔄 Action '{}' has no special handling in emulation mode",
543 action_name
544 ));
545 }
546
547 Ok(())
549}
550
551fn check_command_available(command: &str, name: &str, install_url: &str) {
553 let is_available = Command::new("which")
554 .arg(command)
555 .output()
556 .map(|output| output.status.success())
557 .unwrap_or(false);
558
559 if !is_available {
560 wrkflw_logging::warning(&format!("{} is required but not found on the system", name));
561 wrkflw_logging::info(&format!(
562 "To use this action, please install {}: {}",
563 name, install_url
564 ));
565 wrkflw_logging::info(&format!(
566 "Continuing emulation, but {} commands will fail",
567 name
568 ));
569 } else {
570 if let Ok(output) = Command::new(command).arg("--version").output() {
572 if output.status.success() {
573 let version = String::from_utf8_lossy(&output.stdout);
574 wrkflw_logging::info(&format!("🔄 Using system {}: {}", name, version.trim()));
575 }
576 }
577 }
578}
579
580#[allow(dead_code)]
582fn add_action_env_vars(
583 env_map: &mut HashMap<String, String>,
584 action: &str,
585 with_params: &Option<HashMap<String, String>>,
586) {
587 if let Some(params) = with_params {
588 if action.starts_with("actions/setup-node") {
589 if let Some(version) = params.get("node-version") {
591 env_map.insert("NODE_VERSION".to_string(), version.clone());
592 }
593
594 env_map.insert(
596 "NPM_CONFIG_PREFIX".to_string(),
597 "/tmp/.npm-global".to_string(),
598 );
599 env_map.insert("PATH".to_string(), "/tmp/.npm-global/bin:$PATH".to_string());
600 } else if action.starts_with("actions/setup-python") {
601 if let Some(version) = params.get("python-version") {
603 env_map.insert("PYTHON_VERSION".to_string(), version.clone());
604 }
605
606 env_map.insert("PIP_CACHE_DIR".to_string(), "/tmp/.pip-cache".to_string());
608 } else if action.starts_with("actions/setup-java") {
609 if let Some(version) = params.get("java-version") {
611 env_map.insert("JAVA_VERSION".to_string(), version.clone());
612 }
613
614 env_map.insert(
616 "JAVA_HOME".to_string(),
617 "/usr/lib/jvm/default-java".to_string(),
618 );
619 }
620 }
621}
622
623pub async fn cleanup_resources() {
625 cleanup_processes().await;
626 cleanup_workspaces().await;
627}
628
629async fn cleanup_processes() {
631 let processes_to_cleanup = {
632 if let Ok(processes) = EMULATION_PROCESSES.lock() {
633 processes.clone()
634 } else {
635 vec![]
636 }
637 };
638
639 for pid in processes_to_cleanup {
640 wrkflw_logging::info(&format!("Cleaning up emulated process: {}", pid));
641
642 #[cfg(unix)]
643 {
644 let _ = Command::new("kill")
646 .arg("-TERM")
647 .arg(pid.to_string())
648 .output();
649 }
650
651 #[cfg(windows)]
652 {
653 let _ = Command::new("taskkill")
655 .arg("/F")
656 .arg("/PID")
657 .arg(pid.to_string())
658 .output();
659 }
660
661 if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
663 processes.retain(|p| *p != pid);
664 }
665 }
666}
667
668async fn cleanup_workspaces() {
670 let workspaces_to_cleanup = {
671 if let Ok(workspaces) = EMULATION_WORKSPACES.lock() {
672 workspaces.clone()
673 } else {
674 vec![]
675 }
676 };
677
678 for workspace_path in workspaces_to_cleanup {
679 wrkflw_logging::info(&format!(
680 "Cleaning up emulation workspace: {}",
681 workspace_path.display()
682 ));
683
684 if workspace_path.exists() {
686 match fs::remove_dir_all(&workspace_path) {
687 Ok(_) => wrkflw_logging::info("Successfully removed workspace directory"),
688 Err(e) => wrkflw_logging::error(&format!("Error removing workspace: {}", e)),
689 }
690 }
691
692 if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
694 workspaces.retain(|w| *w != workspace_path);
695 }
696 }
697}
698
699#[allow(dead_code)]
701pub fn track_process(pid: u32) {
702 if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
703 processes.push(pid);
704 }
705}
706
707#[allow(dead_code)]
709pub fn untrack_process(pid: u32) {
710 if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
711 processes.retain(|p| *p != pid);
712 }
713}
714
715#[allow(dead_code)]
717pub fn track_workspace(path: &Path) {
718 if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
719 workspaces.push(path.to_path_buf());
720 }
721}
722
723#[allow(dead_code)]
725pub fn untrack_workspace(path: &Path) {
726 if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
727 workspaces.retain(|w| *w != path);
728 }
729}
730
731#[cfg(test)]
733pub fn get_tracked_workspaces() -> Vec<PathBuf> {
734 if let Ok(workspaces) = EMULATION_WORKSPACES.lock() {
735 workspaces.clone()
736 } else {
737 vec![]
738 }
739}
740
741#[cfg(test)]
742pub fn get_tracked_processes() -> Vec<u32> {
743 if let Ok(processes) = EMULATION_PROCESSES.lock() {
744 processes.clone()
745 } else {
746 vec![]
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 #[tokio::test]
755 async fn test_nonzero_exit_code_returns_ok() {
756 let runtime = EmulationRuntime::new();
757 let result = runtime
758 .run_container(
759 "alpine:latest",
760 &["exit", "42"],
761 &[],
762 Path::new("."),
763 &[(Path::new("."), Path::new("/github/workspace"))],
764 None,
765 )
766 .await;
767
768 let output = result.expect("non-zero exit should return Ok, not Err");
769 assert_eq!(output.exit_code, 42);
770 }
771
772 #[tokio::test]
773 async fn test_empty_cmd_returns_failure_in_emulation() {
774 let runtime = EmulationRuntime::new();
775 let result = runtime
776 .run_container(
777 "some-action:latest",
778 &[],
779 &[],
780 Path::new("."),
781 &[(Path::new("."), Path::new("/github/workspace"))],
782 None,
783 )
784 .await;
785
786 let output = result.expect("empty cmd should return Ok with exit_code 1, not Err");
787 assert_eq!(output.exit_code, 1, "empty cmd should signal failure");
788 assert!(
789 output.stderr.contains("skipped Docker action"),
790 "stderr should explain the action was not executed"
791 );
792 assert!(output.stdout.is_empty(), "stdout should be empty");
793 }
794
795 #[tokio::test]
800 async fn emulation_errors_when_volumes_dont_cover_working_dir() {
801 let runtime = EmulationRuntime::new();
802
803 let result = runtime
805 .run_container(
806 "alpine:latest",
807 &["echo", "nope"],
808 &[],
809 Path::new("/github/workspace"),
810 &[],
811 None,
812 )
813 .await;
814
815 let err = result.expect_err("should error when no volume covers working_dir");
816 let msg = err.to_string();
817 assert!(
818 msg.contains("not covered by any volume mount"),
819 "unexpected error: {}",
820 msg
821 );
822 assert!(
823 msg.contains("emulation:"),
824 "error should carry runtime label, got: {}",
825 msg
826 );
827 }
828}