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#[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 #[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 #[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 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 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 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 #[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 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 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 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 std::fs::write(&dockerfile_path, dockerfile_content).map_err(|e| {
266 ContainerError::ContainerStart(format!("Failed to write Dockerfile: {}", e))
267 })?;
268
269 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 Self::set_language_specific_image("", language, version, &image_tag);
276
277 Ok(image_tag)
278 }
279}
280
281pub fn is_available() -> bool {
282 let overall_timeout = std::time::Duration::from_secs(3);
284
285 let handle = std::thread::spawn(move || {
287 match fd::with_stderr_to_null(|| {
289 if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
291 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 let status = std::thread::scope(|_| {
304 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 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 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 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 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
423pub fn track_container(id: &str) {
425 if let Ok(mut containers) = RUNNING_CONTAINERS.lock() {
426 containers.push(id.to_string());
427 }
428}
429
430pub fn untrack_container(id: &str) {
432 if let Ok(mut containers) = RUNNING_CONTAINERS.lock() {
433 containers.retain(|c| c != id);
434 }
435}
436
437pub fn track_network(id: &str) {
439 if let Ok(mut networks) = CREATED_NETWORKS.lock() {
440 networks.push(id.to_string());
441 }
442}
443
444pub fn untrack_network(id: &str) {
446 if let Ok(mut networks) = CREATED_NETWORKS.lock() {
447 networks.retain(|n| n != id);
448 }
449}
450
451pub async fn cleanup_resources(docker: &Docker) {
453 let cleanup_timeout = std::time::Duration::from_secs(5);
455
456 match tokio::time::timeout(cleanup_timeout, async {
457 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
478pub async fn cleanup_containers(docker: &Docker) -> Result<(), String> {
480 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 for container_id in containers_to_cleanup {
511 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 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 untrack_container(&container_id);
547 }
548
549 Ok(())
550}
551
552pub async fn cleanup_networks(docker: &Docker) -> Result<(), String> {
554 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 untrack_network(&network_id);
601 }
602
603 Ok(())
604}
605
606pub 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 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 wrkflw_logging::info(&format!("Docker: Running container with image: {}", image));
645
646 let timeout_duration = std::time::Duration::from_secs(360); 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 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 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 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 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 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 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 std::fs::write(&dockerfile_path, dockerfile_content).map_err(|e| {
841 ContainerError::ContainerStart(format!("Failed to write Dockerfile: {}", e))
842 })?;
843
844 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 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
869impl 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 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 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 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 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 if is_macos_emu {
923 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 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 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 let mut config = Config {
958 image: Some(image.to_string()),
959 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 user: if is_windows_image {
966 Some("ContainerAdministrator".to_string())
967 } else {
968 None },
970 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 Some(vec!["bash".to_string(), "-l".to_string(), "-c".to_string()])
980 } else {
981 None
982 },
983 ..Default::default()
984 };
985
986 if is_macos_emu {
988 let mut labels = HashMap::new();
990 labels.insert("wrkflw.platform".to_string(), "macos".to_string());
991 config.labels = Some(labels);
992 }
993
994 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_container(&container.id);
1013
1014 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 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 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 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 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 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 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 untrack_container(&container.id);
1108 }
1109
1110 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 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 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 const MAX_CONTEXT_BYTES: u64 = 500 * 1024 * 1024;
1187
1188 let tar_buffer = {
1191 let mut tar_builder = tar::Builder::new(Vec::new());
1192
1193 tar_builder.follow_symlinks(false);
1196
1197 let mut total_bytes: u64 = 0;
1199
1200 use ignore::WalkBuilder;
1203
1204 let dockerignore_path = context_dir.join(".dockerignore");
1205 let mut walker_builder = WalkBuilder::new(context_dir);
1206 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 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 }
1276 Err(e) => {
1277 return Err(ContainerError::ImageBuild(e.to_string()));
1278 }
1279 }
1280 }
1281
1282 Ok(())
1283 }
1284}
1285
1286#[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}