1use async_trait::async_trait;
2use std::fs;
3use std::path::{Path, PathBuf};
4use wrkflw_logging;
5
6pub const LOCAL_IMAGE_PREFIX: &str = "wrkflw-";
8
9pub const COMBINED_IMAGE_PREFIX: &str = "wrkflw-combined:";
11
12#[async_trait]
13pub trait ContainerRuntime {
14 async fn run_container(
23 &self,
24 image: &str,
25 cmd: &[&str],
26 env_vars: &[(&str, &str)],
27 working_dir: &Path,
28 volumes: &[(&Path, &Path)],
29 entrypoint: Option<&str>,
30 ) -> Result<ContainerOutput, ContainerError>;
31
32 async fn pull_image(&self, image: &str) -> Result<(), ContainerError>;
33
34 async fn build_image(
35 &self,
36 dockerfile: &Path,
37 tag: &str,
38 context_dir: &Path,
39 ) -> Result<(), ContainerError>;
40
41 async fn prepare_language_environment(
42 &self,
43 language: &str,
44 version: Option<&str>,
45 additional_packages: Option<Vec<String>>,
46 ) -> Result<String, ContainerError>;
47
48 async fn image_exists(&self, tag: &str) -> Result<bool, ContainerError>;
50}
51
52#[derive(Debug)]
53#[must_use]
54pub struct ContainerOutput {
55 pub stdout: String,
56 pub stderr: String,
57 pub exit_code: i32,
58}
59
60use std::fmt;
61
62#[derive(Debug)]
63pub enum ContainerError {
64 ImagePull(String),
65 ImageBuild(String),
66 ContainerStart(String),
67 ContainerExecution(String),
68 NetworkCreation(String),
69 NetworkOperation(String),
70}
71
72pub(crate) fn resolve_host_working_dir(
88 container_dir: &Path,
89 volumes: &[(&Path, &Path)],
90) -> Option<PathBuf> {
91 let mut best: Option<(usize, PathBuf)> = None;
92 for (host, container) in volumes {
93 if let Ok(suffix) = container_dir.strip_prefix(container) {
94 let depth = container.components().count();
97 let candidate = host.join(suffix);
98 match &best {
99 Some((best_depth, _)) if *best_depth >= depth => {}
107 _ => best = Some((depth, candidate)),
108 }
109 }
110 }
111 best.map(|(_, path)| path)
112}
113
114pub(crate) fn rebase_working_dir_or_error(
134 working_dir: &Path,
135 volumes: &[(&Path, &Path)],
136 runtime_label: &str,
137) -> Result<PathBuf, ContainerError> {
138 match resolve_host_working_dir(working_dir, volumes) {
139 Some(host) => {
140 if !host.exists() {
141 fs::create_dir_all(&host).map_err(|e| {
142 ContainerError::ContainerExecution(format!(
143 "{}: failed to create host working directory '{}': {}",
144 runtime_label,
145 host.display(),
146 e
147 ))
148 })?;
149 }
150 wrkflw_logging::info(&format!(
151 "{}: rebased container path '{}' to host path '{}' via volume mount",
152 runtime_label,
153 working_dir.display(),
154 host.display()
155 ));
156 Ok(host)
157 }
158 None if working_dir.exists() => Ok(working_dir.to_path_buf()),
159 None => Err(ContainerError::ContainerExecution(format!(
160 "{}: container working dir '{}' is not covered by any volume mount; \
161 caller must pass volumes",
162 runtime_label,
163 working_dir.display()
164 ))),
165 }
166}
167
168impl fmt::Display for ContainerError {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 match self {
171 ContainerError::ImagePull(msg) => write!(f, "Failed to pull image: {}", msg),
172 ContainerError::ImageBuild(msg) => write!(f, "Failed to build image: {}", msg),
173 ContainerError::ContainerStart(msg) => {
174 write!(f, "Failed to start container: {}", msg)
175 }
176 ContainerError::ContainerExecution(msg) => {
177 write!(f, "Container execution failed: {}", msg)
178 }
179 ContainerError::NetworkCreation(msg) => {
180 write!(f, "Failed to create Docker network: {}", msg)
181 }
182 ContainerError::NetworkOperation(msg) => {
183 write!(f, "Network operation failed: {}", msg)
184 }
185 }
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn resolve_host_working_dir_exact_match() {
195 let host = Path::new("/host/tmp/job");
196 let container = Path::new("/github/workspace");
197 let volumes = [(host, container)];
198 assert_eq!(
199 resolve_host_working_dir(Path::new("/github/workspace"), &volumes),
200 Some(PathBuf::from("/host/tmp/job"))
201 );
202 }
203
204 #[test]
205 fn resolve_host_working_dir_sub_path() {
206 let host = Path::new("/host/tmp/job");
207 let container = Path::new("/github/workspace");
208 let volumes = [(host, container)];
209 assert_eq!(
210 resolve_host_working_dir(Path::new("/github/workspace/src/lib"), &volumes),
211 Some(PathBuf::from("/host/tmp/job/src/lib"))
212 );
213 }
214
215 #[test]
216 fn resolve_host_working_dir_longest_prefix_wins() {
217 let outer_host = Path::new("/host/outer");
218 let outer_container = Path::new("/a");
219 let inner_host = Path::new("/host/inner");
220 let inner_container = Path::new("/a/b");
221 let volumes = [(outer_host, outer_container), (inner_host, inner_container)];
223 assert_eq!(
224 resolve_host_working_dir(Path::new("/a/b/c"), &volumes),
225 Some(PathBuf::from("/host/inner/c"))
226 );
227 let reversed = [(inner_host, inner_container), (outer_host, outer_container)];
228 assert_eq!(
229 resolve_host_working_dir(Path::new("/a/b/c"), &reversed),
230 Some(PathBuf::from("/host/inner/c"))
231 );
232 }
233
234 #[test]
235 fn resolve_host_working_dir_no_match() {
236 let host = Path::new("/host/tmp/job");
237 let container = Path::new("/different");
238 let volumes = [(host, container)];
239 assert_eq!(
240 resolve_host_working_dir(Path::new("/github/workspace"), &volumes),
241 None
242 );
243 }
244
245 #[test]
246 fn resolve_host_working_dir_empty_volumes() {
247 assert_eq!(
248 resolve_host_working_dir(Path::new("/github/workspace"), &[]),
249 None
250 );
251 }
252
253 #[test]
258 fn resolve_host_working_dir_component_boundary_is_respected() {
259 let host = Path::new("/host/tmp/job");
260 let container = Path::new("/github/workspace");
261 let volumes = [(host, container)];
262 assert_eq!(
263 resolve_host_working_dir(Path::new("/github/workspace-foo"), &volumes),
264 None
265 );
266 }
267
268 #[test]
274 fn rebase_prefers_volume_mapping_over_accidentally_existing_host_path() {
275 let existing = tempfile::tempdir().unwrap();
279 let mapped = tempfile::tempdir().unwrap();
280 let volumes = [(mapped.path(), existing.path())];
281
282 let resolved = rebase_working_dir_or_error(existing.path(), &volumes, "test").unwrap();
283 assert_eq!(resolved, mapped.path().to_path_buf());
284 }
285
286 #[test]
287 fn rebase_accepts_existing_host_path_when_no_volume_covers_it() {
288 let host_dir = tempfile::tempdir().unwrap();
289 let resolved = rebase_working_dir_or_error(host_dir.path(), &[], "test").unwrap();
290 assert_eq!(resolved, host_dir.path().to_path_buf());
291 }
292
293 #[test]
294 fn rebase_errors_loudly_when_no_volume_and_path_does_not_exist() {
295 let err = rebase_working_dir_or_error(
296 Path::new("/definitely/does/not/exist/wrkflw-test"),
297 &[],
298 "test",
299 )
300 .expect_err("should error");
301 let msg = err.to_string();
302 assert!(
303 msg.contains("not covered by any volume mount"),
304 "unexpected error: {}",
305 msg
306 );
307 assert!(msg.contains("test:"), "error should carry runtime label");
308 }
309
310 #[test]
311 fn rebase_creates_host_side_of_mount_if_missing() {
312 let host_root = tempfile::tempdir().unwrap();
317 let container_root = Path::new("/github/workspace");
318 let volumes = [(host_root.path(), container_root)];
319 let container_sub = Path::new("/github/workspace/sub/nested");
320
321 let resolved = rebase_working_dir_or_error(container_sub, &volumes, "test").unwrap();
322 let expected = host_root.path().join("sub/nested");
323 assert_eq!(resolved, expected);
324 assert!(expected.exists(), "helper should have created the subdir");
325 }
326}