Skip to main content

wrkflw_runtime/
container.rs

1use async_trait::async_trait;
2use std::fs;
3use std::path::{Path, PathBuf};
4use wrkflw_logging;
5
6/// Prefix for all locally-built images. Used to skip registry pulls.
7pub const LOCAL_IMAGE_PREFIX: &str = "wrkflw-";
8
9/// Prefix for combined runtime images built by `resolve_runner_image`.
10pub const COMBINED_IMAGE_PREFIX: &str = "wrkflw-combined:";
11
12#[async_trait]
13pub trait ContainerRuntime {
14    /// Run a command inside a container.
15    ///
16    /// If `cmd` is empty (`&[]`), the container runs with the image's built-in
17    /// ENTRYPOINT/CMD. This is used for Docker-type GitHub Actions whose
18    /// entrypoint is baked into the image.
19    ///
20    /// `entrypoint` optionally overrides the image's ENTRYPOINT (used when an
21    /// action.yml declares `runs.entrypoint`).
22    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    /// Check whether a Docker/OCI image exists locally.
49    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
72/// Rebase a container-visible working directory onto its host-side volume
73/// source.
74///
75/// Given a `container_dir` like `/github/workspace/sub` and a `volumes` list
76/// that maps `(host, container)` pairs (e.g. `(/tmp/job-xxxx, /github/workspace)`),
77/// return the corresponding host path (`/tmp/job-xxxx/sub`) by locating the
78/// longest `container` path that is a component-boundary prefix of
79/// `container_dir` and grafting the remainder onto its `host` counterpart.
80///
81/// Returns `None` if no volume covers `container_dir`.
82///
83/// This is the mount-semantics bridge used by non-container runtimes
84/// (emulation, secure_emulation) so that a `run:` step and an
85/// artifact/cache handler observe the same host workspace. It is the fix
86/// for #88.
87pub(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            // `Path::strip_prefix` respects component boundaries, so
95            // `/github/workspace-foo` is NOT matched by `/github/workspace`.
96            let depth = container.components().count();
97            let candidate = host.join(suffix);
98            match &best {
99                // Equal-depth ties can only occur when two volume entries
100                // share the same `container` prefix (two distinct container
101                // paths that are both strict component-boundary prefixes of
102                // the same `container_dir` must have different component
103                // counts, because one has to contain the other). In that
104                // duplicate-entry case, first-seen wins — the `>=` is the
105                // intentional conflict resolution, not a typo for `>`.
106                Some((best_depth, _)) if *best_depth >= depth => {}
107                _ => best = Some((depth, candidate)),
108            }
109        }
110    }
111    best.map(|(_, path)| path)
112}
113
114/// Resolve the host working directory for a non-container runtime call, or
115/// return a `ContainerError` describing why it couldn't.
116///
117/// This is the shared wiring used by `EmulationRuntime::run_container` and
118/// `SecureEmulationRuntime::run_container`. It enforces one invariant: when
119/// `volumes` covers `working_dir`, the volume mapping **always wins** over
120/// any accidentally-existing host path — so a dev-environment quirk like
121/// `/github/workspace` happening to exist on the host cannot silently skip
122/// the rebase and reintroduce #88.
123///
124/// - If a volume covers `working_dir`, rebase it, `create_dir_all` the host
125///   side if it doesn't exist yet (matching docker's bind-mount behavior of
126///   creating the mount target on first access), and return the host path.
127/// - If no volume covers `working_dir` but `working_dir` itself exists on
128///   the host, accept it as a caller-provided host path.
129/// - Otherwise, return a loud, descriptive error. No silent fallback.
130///
131/// `runtime_label` is used as a prefix in log and error messages so the
132/// reader can tell which runtime produced a given line.
133pub(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        // Order shouldn't matter — longest prefix always wins.
222        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    /// Critical: a string-prefix match would incorrectly rebase
254    /// `/github/workspace-foo` onto the mount for `/github/workspace`.
255    /// `Path::strip_prefix` respects component boundaries, so this must
256    /// return `None`.
257    #[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    /// Core invariant of `rebase_working_dir_or_error`: when a volume
269    /// covers `working_dir`, the rebase wins even if `working_dir` also
270    /// happens to exist on the host. Without this, a dev environment with
271    /// a real `/github/workspace` directory would silently skip the rebase
272    /// and reintroduce the #88 class of bug.
273    #[test]
274    fn rebase_prefers_volume_mapping_over_accidentally_existing_host_path() {
275        // `container_dir` points at a real host tempdir (so `.exists()`
276        // returns true), but we also supply a volume mapping that claims
277        // that same path for a different host location. The volume must win.
278        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        // Simulate `working-directory: sub` pointing at a container subdir
313        // that hasn't been created yet. The helper must `create_dir_all`
314        // the host side so the subsequent `Command` can `current_dir` into
315        // it. This matches docker's bind-mount behavior.
316        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}