Skip to main content

wrkflw_executor/
docker.rs

1use async_trait::async_trait;
2use bollard::{
3    container::{Config, CreateContainerOptions},
4    models::HostConfig,
5    network::CreateNetworkOptions,
6    Docker,
7};
8use futures_util::StreamExt;
9use once_cell::sync::Lazy;
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::Mutex;
13use wrkflw_logging;
14use wrkflw_runtime::container::{
15    ContainerError, ContainerOutput, ContainerRuntime, COMBINED_IMAGE_PREFIX, LOCAL_IMAGE_PREFIX,
16};
17use wrkflw_utils;
18use wrkflw_utils::fd;
19
20static RUNNING_CONTAINERS: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
21static CREATED_NETWORKS: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
22// Map to track customized images for a job
23#[allow(dead_code)]
24static CUSTOMIZED_IMAGES: Lazy<Mutex<HashMap<String, String>>> =
25    Lazy::new(|| Mutex::new(HashMap::new()));
26
27pub struct DockerRuntime {
28    docker: Docker,
29    preserve_containers_on_failure: bool,
30}
31
32impl DockerRuntime {
33    pub fn new() -> Result<Self, ContainerError> {
34        Self::new_with_config(false)
35    }
36
37    pub fn new_with_config(preserve_containers_on_failure: bool) -> Result<Self, ContainerError> {
38        let docker = Docker::connect_with_local_defaults().map_err(|e| {
39            ContainerError::ContainerStart(format!("Failed to connect to Docker: {}", e))
40        })?;
41
42        Ok(DockerRuntime {
43            docker,
44            preserve_containers_on_failure,
45        })
46    }
47
48    // Add a method to store and retrieve customized images (e.g., with Python installed)
49    #[allow(dead_code)]
50    pub fn get_customized_image(base_image: &str, customization: &str) -> Option<String> {
51        let key = format!("{}:{}", base_image, customization);
52        match CUSTOMIZED_IMAGES.lock() {
53            Ok(images) => images.get(&key).cloned(),
54            Err(e) => {
55                wrkflw_logging::error(&format!("Failed to acquire lock: {}", e));
56                None
57            }
58        }
59    }
60
61    #[allow(dead_code)]
62    pub fn set_customized_image(base_image: &str, customization: &str, new_image: &str) {
63        let key = format!("{}:{}", base_image, customization);
64        if let Err(e) = CUSTOMIZED_IMAGES.lock().map(|mut images| {
65            images.insert(key, new_image.to_string());
66        }) {
67            wrkflw_logging::error(&format!("Failed to acquire lock: {}", e));
68        }
69    }
70
71    /// Find a customized image key by prefix
72    #[allow(dead_code)]
73    pub fn find_customized_image_key(image: &str, prefix: &str) -> Option<String> {
74        let image_keys = match CUSTOMIZED_IMAGES.lock() {
75            Ok(keys) => keys,
76            Err(e) => {
77                wrkflw_logging::error(&format!("Failed to acquire lock: {}", e));
78                return None;
79            }
80        };
81
82        // Look for any key that starts with the prefix
83        for (key, _) in image_keys.iter() {
84            if key.starts_with(prefix) {
85                return Some(key.clone());
86            }
87        }
88
89        None
90    }
91
92    /// Get a customized image with language-specific dependencies
93    pub fn get_language_specific_image(
94        base_image: &str,
95        language: &str,
96        version: Option<&str>,
97    ) -> Option<String> {
98        let key = match (language, version) {
99            ("python", Some(ver)) => format!("python:{}", ver),
100            ("node", Some(ver)) => format!("node:{}", ver),
101            ("java", Some(ver)) => format!("eclipse-temurin:{}", ver),
102            ("go", Some(ver)) => format!("golang:{}", ver),
103            ("dotnet", Some(ver)) => format!("mcr.microsoft.com/dotnet/sdk:{}", ver),
104            ("rust", Some(ver)) => format!("rust:{}", ver),
105            (lang, Some(ver)) => format!("{}:{}", lang, ver),
106            (lang, None) => lang.to_string(),
107        };
108
109        match CUSTOMIZED_IMAGES.lock() {
110            Ok(images) => images.get(&key).cloned(),
111            Err(e) => {
112                wrkflw_logging::error(&format!("Failed to acquire lock: {}", e));
113                None
114            }
115        }
116    }
117
118    /// Set a customized image with language-specific dependencies
119    pub fn set_language_specific_image(
120        base_image: &str,
121        language: &str,
122        version: Option<&str>,
123        new_image: &str,
124    ) {
125        let key = match (language, version) {
126            ("python", Some(ver)) => format!("python:{}", ver),
127            ("node", Some(ver)) => format!("node:{}", ver),
128            ("java", Some(ver)) => format!("eclipse-temurin:{}", ver),
129            ("go", Some(ver)) => format!("golang:{}", ver),
130            ("dotnet", Some(ver)) => format!("mcr.microsoft.com/dotnet/sdk:{}", ver),
131            ("rust", Some(ver)) => format!("rust:{}", ver),
132            (lang, Some(ver)) => format!("{}:{}", lang, ver),
133            (lang, None) => lang.to_string(),
134        };
135
136        if let Err(e) = CUSTOMIZED_IMAGES.lock().map(|mut images| {
137            images.insert(key, new_image.to_string());
138        }) {
139            wrkflw_logging::error(&format!("Failed to acquire lock: {}", e));
140        }
141    }
142
143    /// Prepare a language-specific environment
144    #[allow(dead_code)]
145    pub async fn prepare_language_environment(
146        &self,
147        language: &str,
148        version: Option<&str>,
149        additional_packages: Option<Vec<String>>,
150    ) -> Result<String, ContainerError> {
151        // Check if we already have a customized image for this language and version
152        let key = format!("{}-{}", language, version.unwrap_or("latest"));
153        if let Some(customized_image) = Self::get_language_specific_image("", language, version) {
154            return Ok(customized_image);
155        }
156
157        // Create a temporary Dockerfile for customization
158        let temp_dir = tempfile::tempdir().map_err(|e| {
159            ContainerError::ContainerStart(format!("Failed to create temp directory: {}", e))
160        })?;
161
162        let dockerfile_path = temp_dir.path().join("Dockerfile");
163        let mut dockerfile_content = String::new();
164
165        // Add language-specific setup based on the language
166        match language {
167            "python" => {
168                let base_image =
169                    version.map_or("python:3.11-slim".to_string(), |v| format!("python:{}", v));
170                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
171                dockerfile_content.push_str(
172                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
173                );
174                dockerfile_content.push_str("    build-essential \\\n");
175                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
176
177                if let Some(packages) = additional_packages {
178                    for package in packages {
179                        dockerfile_content.push_str(&format!("RUN pip install {}\n", package));
180                    }
181                }
182            }
183            "node" => {
184                let base_image =
185                    version.map_or("node:20-slim".to_string(), |v| format!("node:{}", v));
186                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
187                dockerfile_content.push_str(
188                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
189                );
190                dockerfile_content.push_str("    build-essential \\\n");
191                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
192
193                if let Some(packages) = additional_packages {
194                    for package in packages {
195                        dockerfile_content.push_str(&format!("RUN npm install -g {}\n", package));
196                    }
197                }
198            }
199            "java" => {
200                let base_image = version.map_or("eclipse-temurin:17-jdk".to_string(), |v| {
201                    format!("eclipse-temurin:{}", v)
202                });
203                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
204                dockerfile_content.push_str(
205                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
206                );
207                dockerfile_content.push_str("    maven \\\n");
208                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
209            }
210            "go" => {
211                let base_image =
212                    version.map_or("golang:1.21-slim".to_string(), |v| format!("golang:{}", v));
213                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
214                dockerfile_content.push_str(
215                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
216                );
217                dockerfile_content.push_str("    git \\\n");
218                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
219
220                if let Some(packages) = additional_packages {
221                    for package in packages {
222                        dockerfile_content.push_str(&format!("RUN go install {}\n", package));
223                    }
224                }
225            }
226            "dotnet" => {
227                let base_image = version
228                    .map_or("mcr.microsoft.com/dotnet/sdk:7.0".to_string(), |v| {
229                        format!("mcr.microsoft.com/dotnet/sdk:{}", v)
230                    });
231                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
232
233                if let Some(packages) = additional_packages {
234                    for package in packages {
235                        dockerfile_content
236                            .push_str(&format!("RUN dotnet tool install -g {}\n", package));
237                    }
238                }
239            }
240            "rust" => {
241                let base_image =
242                    version.map_or("rust:latest".to_string(), |v| format!("rust:{}", v));
243                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
244                dockerfile_content.push_str(
245                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
246                );
247                dockerfile_content.push_str("    build-essential \\\n");
248                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
249
250                if let Some(packages) = additional_packages {
251                    for package in packages {
252                        dockerfile_content.push_str(&format!("RUN cargo install {}\n", package));
253                    }
254                }
255            }
256            _ => {
257                return Err(ContainerError::ContainerStart(format!(
258                    "Unsupported language: {}",
259                    language
260                )));
261            }
262        }
263
264        // Write the Dockerfile
265        std::fs::write(&dockerfile_path, dockerfile_content).map_err(|e| {
266            ContainerError::ContainerStart(format!("Failed to write Dockerfile: {}", e))
267        })?;
268
269        // Build the customized image
270        let image_tag = format!("wrkflw-{}-{}", language, version.unwrap_or("latest"));
271        self.build_image(&dockerfile_path, &image_tag, temp_dir.path())
272            .await?;
273
274        // Store the customized image
275        Self::set_language_specific_image("", language, version, &image_tag);
276
277        Ok(image_tag)
278    }
279}
280
281pub fn is_available() -> bool {
282    // Use a very short timeout for the entire availability check
283    let overall_timeout = std::time::Duration::from_secs(3);
284
285    // Spawn a thread with the timeout to prevent blocking the main thread
286    let handle = std::thread::spawn(move || {
287        // Use safe FD redirection utility to suppress Docker error messages
288        match fd::with_stderr_to_null(|| {
289            // First, check if docker CLI is available as a quick test
290            if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
291                // Try a simple docker version command with a short timeout
292                let process = std::process::Command::new("docker")
293                    .arg("version")
294                    .arg("--format")
295                    .arg("{{.Server.Version}}")
296                    .stdout(std::process::Stdio::null())
297                    .stderr(std::process::Stdio::null())
298                    .spawn();
299
300                match process {
301                    Ok(mut child) => {
302                        // Set a very short timeout for the process
303                        let status = std::thread::scope(|_| {
304                            // Try to wait for a short time
305                            for _ in 0..10 {
306                                match child.try_wait() {
307                                    Ok(Some(status)) => return status.success(),
308                                    Ok(None) => {
309                                        std::thread::sleep(std::time::Duration::from_millis(100))
310                                    }
311                                    Err(_) => return false,
312                                }
313                            }
314                            // Kill it if it takes too long
315                            let _ = child.kill();
316                            false
317                        });
318
319                        if !status {
320                            return false;
321                        }
322                    }
323                    Err(_) => {
324                        wrkflw_logging::debug("Docker CLI is not available");
325                        return false;
326                    }
327                }
328            }
329
330            // Try to connect to Docker daemon with a short timeout
331            let runtime = match tokio::runtime::Builder::new_current_thread()
332                .enable_all()
333                .build()
334            {
335                Ok(rt) => rt,
336                Err(e) => {
337                    wrkflw_logging::error(&format!(
338                        "Failed to create runtime for Docker availability check: {}",
339                        e
340                    ));
341                    return false;
342                }
343            };
344
345            runtime.block_on(async {
346                match tokio::time::timeout(std::time::Duration::from_secs(2), async {
347                    match Docker::connect_with_local_defaults() {
348                        Ok(docker) => {
349                            // Try to ping the Docker daemon with a short timeout
350                            match tokio::time::timeout(
351                                std::time::Duration::from_secs(1),
352                                docker.ping(),
353                            )
354                            .await
355                            {
356                                Ok(Ok(_)) => true,
357                                Ok(Err(e)) => {
358                                    wrkflw_logging::debug(&format!(
359                                        "Docker daemon ping failed: {}",
360                                        e
361                                    ));
362                                    false
363                                }
364                                Err(_) => {
365                                    wrkflw_logging::debug(
366                                        "Docker daemon ping timed out after 1 second",
367                                    );
368                                    false
369                                }
370                            }
371                        }
372                        Err(e) => {
373                            wrkflw_logging::debug(&format!(
374                                "Docker daemon connection failed: {}",
375                                e
376                            ));
377                            false
378                        }
379                    }
380                })
381                .await
382                {
383                    Ok(result) => result,
384                    Err(_) => {
385                        wrkflw_logging::debug("Docker availability check timed out");
386                        false
387                    }
388                }
389            })
390        }) {
391            Ok(result) => result,
392            Err(_) => {
393                wrkflw_logging::debug(
394                    "Failed to redirect stderr when checking Docker availability",
395                );
396                false
397            }
398        }
399    });
400
401    // Manual implementation of join with timeout
402    let start = std::time::Instant::now();
403
404    while start.elapsed() < overall_timeout {
405        if handle.is_finished() {
406            return match handle.join() {
407                Ok(result) => result,
408                Err(_) => {
409                    wrkflw_logging::warning("Docker availability check thread panicked");
410                    false
411                }
412            };
413        }
414        std::thread::sleep(std::time::Duration::from_millis(50));
415    }
416
417    wrkflw_logging::warning(
418        "Docker availability check timed out, assuming Docker is not available",
419    );
420    false
421}
422
423// Add container to tracking
424pub fn track_container(id: &str) {
425    if let Ok(mut containers) = RUNNING_CONTAINERS.lock() {
426        containers.push(id.to_string());
427    }
428}
429
430// Remove container from tracking
431pub fn untrack_container(id: &str) {
432    if let Ok(mut containers) = RUNNING_CONTAINERS.lock() {
433        containers.retain(|c| c != id);
434    }
435}
436
437// Add network to tracking
438pub fn track_network(id: &str) {
439    if let Ok(mut networks) = CREATED_NETWORKS.lock() {
440        networks.push(id.to_string());
441    }
442}
443
444// Remove network from tracking
445pub fn untrack_network(id: &str) {
446    if let Ok(mut networks) = CREATED_NETWORKS.lock() {
447        networks.retain(|n| n != id);
448    }
449}
450
451// Clean up all tracked resources
452pub async fn cleanup_resources(docker: &Docker) {
453    // Use a global timeout for the entire cleanup process
454    let cleanup_timeout = std::time::Duration::from_secs(5);
455
456    match tokio::time::timeout(cleanup_timeout, async {
457        // Perform both cleanups in parallel for efficiency
458        let (container_result, network_result) =
459            tokio::join!(cleanup_containers(docker), cleanup_networks(docker));
460
461        if let Err(e) = container_result {
462            wrkflw_logging::error(&format!("Error during container cleanup: {}", e));
463        }
464
465        if let Err(e) = network_result {
466            wrkflw_logging::error(&format!("Error during network cleanup: {}", e));
467        }
468    })
469    .await
470    {
471        Ok(_) => wrkflw_logging::debug("Docker cleanup completed within timeout"),
472        Err(_) => wrkflw_logging::warning(
473            "Docker cleanup timed out, some resources may not have been removed",
474        ),
475    }
476}
477
478// Clean up all tracked containers
479pub async fn cleanup_containers(docker: &Docker) -> Result<(), String> {
480    // Getting the containers to clean up should not take a long time
481    let containers_to_cleanup =
482        match tokio::time::timeout(std::time::Duration::from_millis(500), async {
483            match RUNNING_CONTAINERS.try_lock() {
484                Ok(containers) => containers.clone(),
485                Err(_) => {
486                    wrkflw_logging::error("Could not acquire container lock for cleanup");
487                    vec![]
488                }
489            }
490        })
491        .await
492        {
493            Ok(containers) => containers,
494            Err(_) => {
495                wrkflw_logging::error("Timeout while trying to get containers for cleanup");
496                vec![]
497            }
498        };
499
500    if containers_to_cleanup.is_empty() {
501        return Ok(());
502    }
503
504    wrkflw_logging::info(&format!(
505        "Cleaning up {} containers",
506        containers_to_cleanup.len()
507    ));
508
509    // Process each container with a timeout
510    for container_id in containers_to_cleanup {
511        // First try to stop the container
512        match tokio::time::timeout(
513            std::time::Duration::from_millis(1000),
514            docker.stop_container(&container_id, None),
515        )
516        .await
517        {
518            Ok(Ok(_)) => wrkflw_logging::debug(&format!("Stopped container: {}", container_id)),
519            Ok(Err(e)) => wrkflw_logging::warning(&format!(
520                "Error stopping container {}: {}",
521                container_id, e
522            )),
523            Err(_) => {
524                wrkflw_logging::warning(&format!("Timeout stopping container: {}", container_id))
525            }
526        }
527
528        // Then try to remove it
529        match tokio::time::timeout(
530            std::time::Duration::from_millis(1000),
531            docker.remove_container(&container_id, None),
532        )
533        .await
534        {
535            Ok(Ok(_)) => wrkflw_logging::debug(&format!("Removed container: {}", container_id)),
536            Ok(Err(e)) => wrkflw_logging::warning(&format!(
537                "Error removing container {}: {}",
538                container_id, e
539            )),
540            Err(_) => {
541                wrkflw_logging::warning(&format!("Timeout removing container: {}", container_id))
542            }
543        }
544
545        // Always untrack the container whether or not we succeeded to avoid future cleanup attempts
546        untrack_container(&container_id);
547    }
548
549    Ok(())
550}
551
552// Clean up all tracked networks
553pub async fn cleanup_networks(docker: &Docker) -> Result<(), String> {
554    // Getting the networks to clean up should not take a long time
555    let networks_to_cleanup =
556        match tokio::time::timeout(std::time::Duration::from_millis(500), async {
557            match CREATED_NETWORKS.try_lock() {
558                Ok(networks) => networks.clone(),
559                Err(_) => {
560                    wrkflw_logging::error("Could not acquire network lock for cleanup");
561                    vec![]
562                }
563            }
564        })
565        .await
566        {
567            Ok(networks) => networks,
568            Err(_) => {
569                wrkflw_logging::error("Timeout while trying to get networks for cleanup");
570                vec![]
571            }
572        };
573
574    if networks_to_cleanup.is_empty() {
575        return Ok(());
576    }
577
578    wrkflw_logging::info(&format!(
579        "Cleaning up {} networks",
580        networks_to_cleanup.len()
581    ));
582
583    for network_id in networks_to_cleanup {
584        match tokio::time::timeout(
585            std::time::Duration::from_millis(1000),
586            docker.remove_network(&network_id),
587        )
588        .await
589        {
590            Ok(Ok(_)) => {
591                wrkflw_logging::info(&format!("Successfully removed network: {}", network_id))
592            }
593            Ok(Err(e)) => {
594                wrkflw_logging::error(&format!("Error removing network {}: {}", network_id, e))
595            }
596            Err(_) => wrkflw_logging::warning(&format!("Timeout removing network: {}", network_id)),
597        }
598
599        // Always untrack the network whether or not we succeeded
600        untrack_network(&network_id);
601    }
602
603    Ok(())
604}
605
606// Create a new Docker network for a job
607pub async fn create_job_network(docker: &Docker) -> Result<String, ContainerError> {
608    let network_name = format!("wrkflw-network-{}", uuid::Uuid::new_v4());
609
610    let options = CreateNetworkOptions {
611        name: network_name.clone(),
612        driver: "bridge".to_string(),
613        ..Default::default()
614    };
615
616    let network = docker
617        .create_network(options)
618        .await
619        .map_err(|e| ContainerError::NetworkCreation(e.to_string()))?;
620
621    // network.id is Option<String>, unwrap it safely
622    let network_id = network.id.ok_or_else(|| {
623        ContainerError::NetworkOperation("Network created but no ID returned".to_string())
624    })?;
625
626    track_network(&network_id);
627    wrkflw_logging::info(&format!("Created Docker network: {}", network_id));
628
629    Ok(network_id)
630}
631
632#[async_trait]
633impl ContainerRuntime for DockerRuntime {
634    async fn run_container(
635        &self,
636        image: &str,
637        cmd: &[&str],
638        env_vars: &[(&str, &str)],
639        working_dir: &Path,
640        volumes: &[(&Path, &Path)],
641        entrypoint: Option<&str>,
642    ) -> Result<ContainerOutput, ContainerError> {
643        // Print detailed debugging info
644        wrkflw_logging::info(&format!("Docker: Running container with image: {}", image));
645
646        // Add a global timeout for all Docker operations to prevent freezing
647        let timeout_duration = std::time::Duration::from_secs(360); // Increased outer timeout to 6 minutes
648
649        // Run the entire container operation with a timeout
650        match tokio::time::timeout(
651            timeout_duration,
652            self.run_container_inner(image, cmd, env_vars, working_dir, volumes, entrypoint),
653        )
654        .await
655        {
656            Ok(result) => result,
657            Err(_) => {
658                wrkflw_logging::error("Docker operation timed out after 360 seconds");
659                Err(ContainerError::ContainerExecution(
660                    "Operation timed out".to_string(),
661                ))
662            }
663        }
664    }
665
666    async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
667        // Add a timeout for pull operations
668        let timeout_duration = std::time::Duration::from_secs(30);
669
670        match tokio::time::timeout(timeout_duration, self.pull_image_inner(image)).await {
671            Ok(result) => result,
672            Err(_) => {
673                wrkflw_logging::warning(&format!(
674                    "Pull of image {} timed out, continuing with existing image",
675                    image
676                ));
677                // Return success to allow continuing with existing image
678                Ok(())
679            }
680        }
681    }
682
683    async fn build_image(
684        &self,
685        dockerfile: &Path,
686        tag: &str,
687        context_dir: &Path,
688    ) -> Result<(), ContainerError> {
689        // Add a timeout for build operations.
690        // Combined runtime images may need to install packages from PPAs
691        // and external sources, so allow up to 10 minutes.
692        // Other builds use a 2 minute timeout.
693        let timeout_secs = if tag.starts_with(COMBINED_IMAGE_PREFIX) {
694            600
695        } else {
696            120
697        };
698        let timeout_duration = std::time::Duration::from_secs(timeout_secs);
699
700        match tokio::time::timeout(
701            timeout_duration,
702            self.build_image_inner(dockerfile, tag, context_dir),
703        )
704        .await
705        {
706            Ok(result) => result,
707            Err(_) => {
708                wrkflw_logging::error(&format!(
709                    "Building image {} timed out after {} seconds",
710                    tag,
711                    timeout_duration.as_secs()
712                ));
713                Err(ContainerError::ImageBuild(
714                    "Operation timed out".to_string(),
715                ))
716            }
717        }
718    }
719
720    async fn prepare_language_environment(
721        &self,
722        language: &str,
723        version: Option<&str>,
724        additional_packages: Option<Vec<String>>,
725    ) -> Result<String, ContainerError> {
726        // Check if we already have a customized image for this language and version
727        let key = format!("{}-{}", language, version.unwrap_or("latest"));
728        if let Some(customized_image) = Self::get_language_specific_image("", language, version) {
729            return Ok(customized_image);
730        }
731
732        // Create a temporary Dockerfile for customization
733        let temp_dir = tempfile::tempdir().map_err(|e| {
734            ContainerError::ContainerStart(format!("Failed to create temp directory: {}", e))
735        })?;
736
737        let dockerfile_path = temp_dir.path().join("Dockerfile");
738        let mut dockerfile_content = String::new();
739
740        // Add language-specific setup based on the language
741        match language {
742            "python" => {
743                let base_image =
744                    version.map_or("python:3.11-slim".to_string(), |v| format!("python:{}", v));
745                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
746                dockerfile_content.push_str(
747                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
748                );
749                dockerfile_content.push_str("    build-essential \\\n");
750                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
751
752                if let Some(packages) = additional_packages {
753                    for package in packages {
754                        dockerfile_content.push_str(&format!("RUN pip install {}\n", package));
755                    }
756                }
757            }
758            "node" => {
759                let base_image =
760                    version.map_or("node:20-slim".to_string(), |v| format!("node:{}", v));
761                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
762                dockerfile_content.push_str(
763                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
764                );
765                dockerfile_content.push_str("    build-essential \\\n");
766                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
767
768                if let Some(packages) = additional_packages {
769                    for package in packages {
770                        dockerfile_content.push_str(&format!("RUN npm install -g {}\n", package));
771                    }
772                }
773            }
774            "java" => {
775                let base_image = version.map_or("eclipse-temurin:17-jdk".to_string(), |v| {
776                    format!("eclipse-temurin:{}", v)
777                });
778                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
779                dockerfile_content.push_str(
780                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
781                );
782                dockerfile_content.push_str("    maven \\\n");
783                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
784            }
785            "go" => {
786                let base_image =
787                    version.map_or("golang:1.21-slim".to_string(), |v| format!("golang:{}", v));
788                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
789                dockerfile_content.push_str(
790                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
791                );
792                dockerfile_content.push_str("    git \\\n");
793                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
794
795                if let Some(packages) = additional_packages {
796                    for package in packages {
797                        dockerfile_content.push_str(&format!("RUN go install {}\n", package));
798                    }
799                }
800            }
801            "dotnet" => {
802                let base_image = version
803                    .map_or("mcr.microsoft.com/dotnet/sdk:7.0".to_string(), |v| {
804                        format!("mcr.microsoft.com/dotnet/sdk:{}", v)
805                    });
806                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
807
808                if let Some(packages) = additional_packages {
809                    for package in packages {
810                        dockerfile_content
811                            .push_str(&format!("RUN dotnet tool install -g {}\n", package));
812                    }
813                }
814            }
815            "rust" => {
816                let base_image =
817                    version.map_or("rust:latest".to_string(), |v| format!("rust:{}", v));
818                dockerfile_content.push_str(&format!("FROM {}\n\n", base_image));
819                dockerfile_content.push_str(
820                    "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
821                );
822                dockerfile_content.push_str("    build-essential \\\n");
823                dockerfile_content.push_str("    && rm -rf /var/lib/apt/lists/*\n");
824
825                if let Some(packages) = additional_packages {
826                    for package in packages {
827                        dockerfile_content.push_str(&format!("RUN cargo install {}\n", package));
828                    }
829                }
830            }
831            _ => {
832                return Err(ContainerError::ContainerStart(format!(
833                    "Unsupported language: {}",
834                    language
835                )));
836            }
837        }
838
839        // Write the Dockerfile
840        std::fs::write(&dockerfile_path, dockerfile_content).map_err(|e| {
841            ContainerError::ContainerStart(format!("Failed to write Dockerfile: {}", e))
842        })?;
843
844        // Build the customized image
845        let image_tag = format!("wrkflw-{}-{}", language, version.unwrap_or("latest"));
846        self.build_image(&dockerfile_path, &image_tag, temp_dir.path())
847            .await?;
848
849        // Store the customized image
850        Self::set_language_specific_image("", language, version, &image_tag);
851
852        Ok(image_tag)
853    }
854
855    async fn image_exists(&self, tag: &str) -> Result<bool, ContainerError> {
856        match self.docker.inspect_image(tag).await {
857            Ok(_) => Ok(true),
858            Err(bollard::errors::Error::DockerResponseServerError {
859                status_code: 404, ..
860            }) => Ok(false),
861            Err(e) => Err(ContainerError::ImageBuild(format!(
862                "Failed to inspect image {}: {}",
863                tag, e
864            ))),
865        }
866    }
867}
868
869// Move the actual implementation to internal methods
870impl DockerRuntime {
871    async fn run_container_inner(
872        &self,
873        image: &str,
874        cmd: &[&str],
875        env_vars: &[(&str, &str)],
876        working_dir: &Path,
877        volumes: &[(&Path, &Path)],
878        entrypoint: Option<&str>,
879    ) -> Result<ContainerOutput, ContainerError> {
880        // Try to pull the image if it's not available locally.
881        // Skip pull for locally-built images (e.g., combined runtime images).
882        if !image.starts_with(LOCAL_IMAGE_PREFIX) {
883            if let Err(e) = self.pull_image_inner(image).await {
884                wrkflw_logging::warning(&format!(
885                    "Failed to pull image {}: {}. Attempting to continue with existing image.",
886                    image, e
887                ));
888            }
889        }
890
891        // Collect environment variables
892        let mut env: Vec<String> = env_vars
893            .iter()
894            .map(|(k, v)| format!("{}={}", k, v))
895            .collect();
896
897        let mut binds = Vec::new();
898        for (host_path, container_path) in volumes {
899            binds.push(format!(
900                "{}:{}",
901                host_path.to_string_lossy(),
902                container_path.to_string_lossy()
903            ));
904        }
905
906        // Convert command vector to Vec<String>
907        let cmd_vec: Vec<String> = cmd.iter().map(|&s| s.to_string()).collect();
908        let has_cmd = !cmd_vec.is_empty();
909
910        wrkflw_logging::debug(&format!("Running command in Docker: {:?}", cmd_vec));
911        wrkflw_logging::debug(&format!("Environment: {:?}", env));
912        wrkflw_logging::debug(&format!("Working directory: {}", working_dir.display()));
913
914        // Determine platform-specific configurations
915        let is_windows_image = image.contains("windows")
916            || image.contains("servercore")
917            || image.contains("nanoserver");
918        let is_macos_emu =
919            image.contains("act-") && (image.contains("catthehacker") || image.contains("nektos"));
920
921        // Add platform-specific environment variables
922        if is_macos_emu {
923            // Add macOS-specific environment variables
924            env.push("RUNNER_OS=macOS".to_string());
925            env.push("RUNNER_ARCH=X64".to_string());
926            env.push("TMPDIR=/tmp".to_string());
927            env.push("HOME=/root".to_string());
928            env.push("GITHUB_WORKSPACE=/github/workspace".to_string());
929            env.push("PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string());
930        }
931
932        // Create appropriate container options based on platform
933        let options = Some(CreateContainerOptions {
934            name: format!("wrkflw-{}", uuid::Uuid::new_v4()),
935            platform: if is_windows_image {
936                Some("windows".to_string())
937            } else {
938                None
939            },
940        });
941
942        // Configure host configuration based on platform
943        let host_config = if is_windows_image {
944            HostConfig {
945                binds: Some(binds),
946                isolation: Some(bollard::models::HostConfigIsolationEnum::PROCESS),
947                ..Default::default()
948            }
949        } else {
950            HostConfig {
951                binds: Some(binds),
952                ..Default::default()
953            }
954        };
955
956        // Create container config with platform-specific settings
957        let mut config = Config {
958            image: Some(image.to_string()),
959            // Empty cmd means "use the image's built-in ENTRYPOINT/CMD"
960            cmd: if has_cmd { Some(cmd_vec) } else { None },
961            env: Some(env),
962            working_dir: Some(working_dir.to_string_lossy().to_string()),
963            host_config: Some(host_config),
964            // Windows containers need specific configuration
965            user: if is_windows_image {
966                Some("ContainerAdministrator".to_string())
967            } else {
968                None // Don't specify user for macOS emulation - use default root user
969            },
970            // Map appropriate entrypoint for different platforms.
971            // Priority: explicit entrypoint from action.yml > macOS bash wrapper > image default.
972            // Only apply the macOS bash wrapper when there is an explicit command to wrap;
973            // an empty cmd means "use the image's native ENTRYPOINT/CMD"
974            // and overriding it with bash would hang or discard the real entrypoint.
975            entrypoint: if let Some(ep) = entrypoint.filter(|s| !s.is_empty()) {
976                Some(vec![ep.to_string()])
977            } else if is_macos_emu && has_cmd {
978                // For macOS, ensure we use bash
979                Some(vec!["bash".to_string(), "-l".to_string(), "-c".to_string()])
980            } else {
981                None
982            },
983            ..Default::default()
984        };
985
986        // Run platform-specific container setup
987        if is_macos_emu {
988            // Add special labels for macOS
989            let mut labels = HashMap::new();
990            labels.insert("wrkflw.platform".to_string(), "macos".to_string());
991            config.labels = Some(labels);
992        }
993
994        // Create container with a shorter timeout
995        let create_result = tokio::time::timeout(
996            std::time::Duration::from_secs(15),
997            self.docker.create_container(options, config),
998        )
999        .await;
1000
1001        let container = match create_result {
1002            Ok(Ok(container)) => container,
1003            Ok(Err(e)) => return Err(ContainerError::ContainerStart(e.to_string())),
1004            Err(_) => {
1005                return Err(ContainerError::ContainerStart(
1006                    "Container creation timed out".to_string(),
1007                ))
1008            }
1009        };
1010
1011        // Track the container before starting it to ensure cleanup even if starting fails
1012        track_container(&container.id);
1013
1014        // Start container with a timeout
1015        let start_result = tokio::time::timeout(
1016            std::time::Duration::from_secs(15),
1017            self.docker.start_container::<String>(&container.id, None),
1018        )
1019        .await;
1020
1021        match start_result {
1022            Ok(Ok(_)) => {}
1023            Ok(Err(e)) => {
1024                // Clean up the container if start fails
1025                let _ = self.docker.remove_container(&container.id, None).await;
1026                untrack_container(&container.id);
1027                return Err(ContainerError::ContainerExecution(e.to_string()));
1028            }
1029            Err(_) => {
1030                // Clean up the container if starting times out
1031                let _ = self.docker.remove_container(&container.id, None).await;
1032                untrack_container(&container.id);
1033                return Err(ContainerError::ContainerExecution(
1034                    "Container start timed out".to_string(),
1035                ));
1036            }
1037        }
1038
1039        // Wait for container to finish with a timeout (300 seconds)
1040        let wait_result = tokio::time::timeout(
1041            std::time::Duration::from_secs(300),
1042            self.docker
1043                .wait_container::<String>(&container.id, None)
1044                .collect::<Vec<_>>(),
1045        )
1046        .await;
1047
1048        let exit_code = match wait_result {
1049            Ok(results) => match results.first() {
1050                Some(Ok(exit)) => exit.status_code as i32,
1051                _ => -1,
1052            },
1053            Err(_) => {
1054                wrkflw_logging::warning("Container wait operation timed out, treating as failure");
1055                -1
1056            }
1057        };
1058
1059        // Get logs with a timeout
1060        let log_options = Some(bollard::container::LogsOptions::<String> {
1061            stdout: true,
1062            stderr: true,
1063            ..Default::default()
1064        });
1065        let logs_result = tokio::time::timeout(
1066            std::time::Duration::from_secs(10),
1067            self.docker
1068                .logs(&container.id, log_options)
1069                .collect::<Vec<_>>(),
1070        )
1071        .await;
1072
1073        let mut stdout = String::new();
1074        let mut stderr = String::new();
1075
1076        if let Ok(logs) = logs_result {
1077            for log in logs.into_iter().flatten() {
1078                match log {
1079                    bollard::container::LogOutput::StdOut { message } => {
1080                        stdout.push_str(&String::from_utf8_lossy(&message));
1081                    }
1082                    bollard::container::LogOutput::StdErr { message } => {
1083                        stderr.push_str(&String::from_utf8_lossy(&message));
1084                    }
1085                    _ => {}
1086                }
1087            }
1088        } else {
1089            wrkflw_logging::warning("Retrieving container logs timed out");
1090        }
1091
1092        // Clean up container with a timeout, but preserve on failure if configured
1093        if exit_code == 0 || !self.preserve_containers_on_failure {
1094            let _ = tokio::time::timeout(
1095                std::time::Duration::from_secs(10),
1096                self.docker.remove_container(&container.id, None),
1097            )
1098            .await;
1099            untrack_container(&container.id);
1100        } else {
1101            // Container failed and we want to preserve it for debugging
1102            wrkflw_logging::info(&format!(
1103                "Preserving container {} for debugging (exit code: {}). Use 'docker exec -it {} bash' to inspect.",
1104                container.id, exit_code, container.id
1105            ));
1106            // Still untrack it from the automatic cleanup system to prevent it from being cleaned up later
1107            untrack_container(&container.id);
1108        }
1109
1110        // Log detailed information about the command execution for debugging
1111        if exit_code != 0 {
1112            wrkflw_logging::info(&format!(
1113                "Docker command failed with exit code: {}",
1114                exit_code
1115            ));
1116            wrkflw_logging::debug(&format!("Failed command: {:?}", cmd));
1117            wrkflw_logging::debug(&format!("Working directory: {}", working_dir.display()));
1118            wrkflw_logging::debug(&format!("STDERR: {}", stderr));
1119        }
1120
1121        Ok(ContainerOutput {
1122            stdout,
1123            stderr,
1124            exit_code,
1125        })
1126    }
1127
1128    async fn pull_image_inner(&self, image: &str) -> Result<(), ContainerError> {
1129        let options = bollard::image::CreateImageOptions {
1130            from_image: image,
1131            ..Default::default()
1132        };
1133
1134        let mut stream = self.docker.create_image(Some(options), None, None);
1135
1136        while let Some(result) = stream.next().await {
1137            if let Err(e) = result {
1138                return Err(ContainerError::ImagePull(e.to_string()));
1139            }
1140        }
1141
1142        Ok(())
1143    }
1144
1145    /// Build a Docker image from a Dockerfile.
1146    ///
1147    /// `context_dir` is the Docker build context directory. Files in this
1148    /// directory are sent to the Docker daemon so that COPY instructions work.
1149    /// The Dockerfile path is made relative to this context for the build API.
1150    ///
1151    /// If a `.dockerignore` file exists in the context directory, its patterns
1152    /// are honoured to exclude files from the build context — reducing the
1153    /// amount of data sent to the daemon for large action repositories.
1154    ///
1155    /// **Note:** `follow_symlinks(false)` protects against symlink-based
1156    /// exfiltration but does not prevent hard-links to files outside the
1157    /// context.  This is acceptable because the context is always a freshly
1158    /// cloned repo or tempdir that we control.
1159    async fn build_image_inner(
1160        &self,
1161        dockerfile: &Path,
1162        tag: &str,
1163        context_dir: &Path,
1164    ) -> Result<(), ContainerError> {
1165        if !dockerfile.exists() {
1166            return Err(ContainerError::ImageBuild(format!(
1167                "Cannot open Dockerfile at {}",
1168                dockerfile.display()
1169            )));
1170        }
1171
1172        // Determine the Dockerfile path relative to the context directory.
1173        let dockerfile_name = dockerfile
1174            .strip_prefix(context_dir)
1175            .map(|p| p.to_string_lossy().to_string())
1176            .map_err(|_| {
1177                ContainerError::ImageBuild(format!(
1178                    "Dockerfile {} is not within context directory {}",
1179                    dockerfile.display(),
1180                    context_dir.display()
1181                ))
1182            })?;
1183
1184        // Maximum build context size (500 MB). Prevents OOM when an action
1185        // repo is unexpectedly large and has no .dockerignore.
1186        const MAX_CONTEXT_BYTES: u64 = 500 * 1024 * 1024;
1187
1188        // TODO: For large build contexts (approaching MAX_CONTEXT_BYTES), consider
1189        // streaming the tar to a temporary file instead of holding it all in memory.
1190        let tar_buffer = {
1191            let mut tar_builder = tar::Builder::new(Vec::new());
1192
1193            // Do not follow symlinks — untrusted action repos could use symlinks
1194            // to leak host filesystem contents into the Docker build context.
1195            tar_builder.follow_symlinks(false);
1196
1197            // Track cumulative size so we can enforce MAX_CONTEXT_BYTES.
1198            let mut total_bytes: u64 = 0;
1199
1200            // Build a context walker. When a .dockerignore exists, its patterns
1201            // are used to exclude files (same glob semantics as Docker).
1202            use ignore::WalkBuilder;
1203
1204            let dockerignore_path = context_dir.join(".dockerignore");
1205            let mut walker_builder = WalkBuilder::new(context_dir);
1206            // Disable default .gitignore / .ignore handling — we only want
1207            // .dockerignore semantics when the file is present.
1208            walker_builder.standard_filters(false).follow_links(false);
1209            if dockerignore_path.exists() {
1210                walker_builder.add_custom_ignore_filename(".dockerignore");
1211            }
1212
1213            for entry in walker_builder.build() {
1214                let entry = entry.map_err(|e| {
1215                    ContainerError::ImageBuild(format!("Failed to walk build context: {}", e))
1216                })?;
1217                let path = entry.path();
1218                let rel = match path.strip_prefix(context_dir) {
1219                    Ok(r) => r,
1220                    Err(_) => continue,
1221                };
1222                // Skip the root directory itself — tar implicitly contains it.
1223                if rel.as_os_str().is_empty() {
1224                    continue;
1225                }
1226                if path.is_dir() {
1227                    tar_builder.append_dir(rel, path).map_err(|e| {
1228                        ContainerError::ImageBuild(format!(
1229                            "Failed to add directory to build context tar: {}",
1230                            e
1231                        ))
1232                    })?;
1233                } else {
1234                    if let Ok(meta) = path.metadata() {
1235                        total_bytes += meta.len();
1236                        if total_bytes > MAX_CONTEXT_BYTES {
1237                            return Err(ContainerError::ImageBuild(format!(
1238                                "Docker build context exceeds {} MB limit. \
1239                                 Add a .dockerignore file to exclude unnecessary files.",
1240                                MAX_CONTEXT_BYTES / (1024 * 1024)
1241                            )));
1242                        }
1243                    }
1244                    tar_builder.append_path_with_name(path, rel).map_err(|e| {
1245                        ContainerError::ImageBuild(format!(
1246                            "Failed to add file to build context tar: {}",
1247                            e
1248                        ))
1249                    })?;
1250                }
1251            }
1252
1253            tar_builder
1254                .into_inner()
1255                .map_err(|e| ContainerError::ImageBuild(e.to_string()))?
1256        };
1257
1258        let options = bollard::image::BuildImageOptions {
1259            dockerfile: dockerfile_name.as_str(),
1260            t: tag,
1261            q: false,
1262            nocache: false,
1263            rm: true,
1264            ..Default::default()
1265        };
1266
1267        let mut stream = self
1268            .docker
1269            .build_image(options, None, Some(tar_buffer.into()));
1270
1271        while let Some(result) = stream.next().await {
1272            match result {
1273                Ok(_) => {
1274                    // For verbose output, we could log the build progress here
1275                }
1276                Err(e) => {
1277                    return Err(ContainerError::ImageBuild(e.to_string()));
1278                }
1279            }
1280        }
1281
1282        Ok(())
1283    }
1284}
1285
1286// Public accessor functions for testing
1287#[cfg(test)]
1288pub fn get_tracked_containers() -> Vec<String> {
1289    if let Ok(containers) = RUNNING_CONTAINERS.lock() {
1290        containers.clone()
1291    } else {
1292        vec![]
1293    }
1294}
1295
1296#[cfg(test)]
1297pub fn get_tracked_networks() -> Vec<String> {
1298    if let Ok(networks) = CREATED_NETWORKS.lock() {
1299        networks.clone()
1300    } else {
1301        vec![]
1302    }
1303}