Skip to main content

bv_runtime/
docker.rs

1use std::collections::HashMap;
2use std::io::{BufRead, BufReader, Read};
3use std::process::{Command, Stdio};
4use std::thread;
5use std::time::Instant;
6
7use bv_core::error::{BvError, Result};
8
9use crate::runtime::{
10    ContainerRuntime, GpuProfile, ImageDigest, ImageMetadata, Mount, OciRef, ProgressReporter,
11    RunOutcome, RunSpec, RuntimeInfo,
12};
13
14#[derive(Clone)]
15pub struct DockerRuntime;
16
17impl ContainerRuntime for DockerRuntime {
18    fn name(&self) -> &str {
19        "docker"
20    }
21
22    fn health_check(&self) -> Result<RuntimeInfo> {
23        let output = Command::new("docker")
24            .arg("version")
25            .output()
26            .map_err(|e| BvError::RuntimeNotAvailable {
27                runtime: "docker".into(),
28                reason: format!("could not execute `docker`: {e}"),
29            })?;
30
31        if !output.status.success() {
32            let stderr = String::from_utf8_lossy(&output.stderr);
33            return Err(BvError::RuntimeNotAvailable {
34                runtime: "docker".into(),
35                reason: format!("docker daemon not running or not accessible: {stderr}"),
36            });
37        }
38
39        let stdout = String::from_utf8_lossy(&output.stdout);
40
41        // Parse "Version: X.Y.Z" lines; stable across Docker Engine and Desktop.
42        let versions: Vec<&str> = stdout
43            .lines()
44            .filter_map(|l| l.trim().strip_prefix("Version:").map(|v| v.trim()))
45            .collect();
46
47        let client_version = versions.first().copied().unwrap_or("unknown").to_string();
48        let server_version = versions.get(1).copied().map(str::to_string);
49
50        let mut extra = HashMap::new();
51        if let Some(sv) = server_version {
52            extra.insert("server_version".into(), sv);
53        }
54
55        Ok(RuntimeInfo {
56            name: "docker".into(),
57            version: client_version,
58            extra,
59        })
60    }
61
62    fn pull(&self, image: &OciRef, progress: &dyn ProgressReporter) -> Result<ImageDigest> {
63        let image_arg = image.docker_arg();
64        progress.update(&format!("Pulling {image_arg}"), None, None);
65
66        let mut child = Command::new("docker")
67            .args(["pull", &image_arg])
68            .stdout(Stdio::piped())
69            .stderr(Stdio::piped())
70            .spawn()
71            .map_err(|e| BvError::RuntimeNotAvailable {
72                runtime: "docker".into(),
73                reason: format!("could not execute `docker`: {e}"),
74            })?;
75
76        let stdout = child.stdout.take().expect("stdout was piped");
77        let stderr = child.stderr.take().expect("stderr was piped");
78
79        // Drain stderr in a background thread to prevent pipe deadlock.
80        let stderr_thread = thread::spawn(move || {
81            let mut s = String::new();
82            BufReader::new(stderr).read_to_string(&mut s).ok();
83            s
84        });
85
86        // Parse docker pull stdout line-by-line for progress and the digest.
87        let mut pull_digest: Option<String> = None;
88        for line in BufReader::new(stdout).lines() {
89            let line = line.map_err(BvError::Io)?;
90            let trimmed = line.trim();
91            // "Digest: sha256:abc123"
92            if let Some(d) = trimmed.strip_prefix("Digest: ") {
93                pull_digest = Some(d.to_string());
94            }
95            progress.update(trimmed, None, None);
96        }
97
98        let status = child.wait()?;
99        let stderr_output = stderr_thread.join().unwrap_or_default();
100
101        if !status.success() {
102            return Err(classify_pull_error(&stderr_output, &image_arg));
103        }
104
105        progress.finish(""); // outer "Added X" line is the summary; avoid redundant URL spam
106
107        let digest = match pull_digest {
108            Some(d) => d,
109            None => self.repo_digest(&image_arg)?,
110        };
111
112        Ok(ImageDigest(digest))
113    }
114
115    fn run(&self, spec: &RunSpec) -> Result<RunOutcome> {
116        let start = Instant::now();
117
118        let mut cmd = Command::new("docker");
119        cmd.arg("run").arg("--rm");
120
121        // Run as the host user so files written into bind mounts are not
122        // owned by root. On non-Unix platforms `current_uid_gid` returns
123        // None and we let docker pick the image default.
124        if let Some((uid, gid)) = current_uid_gid() {
125            cmd.args(["--user", &format!("{uid}:{gid}")]);
126        }
127
128        if let Some(wd) = &spec.working_dir {
129            cmd.args(["-w", &wd.to_string_lossy()]);
130        }
131
132        for arg in self.mount_args(&spec.mounts) {
133            cmd.arg(arg);
134        }
135
136        for (k, v) in &spec.env {
137            cmd.arg("-e").arg(format!("{k}={v}"));
138        }
139
140        for arg in self.gpu_args(&spec.gpu) {
141            cmd.arg(arg);
142        }
143
144        // Pass NVIDIA_VISIBLE_DEVICES through if the user has set it.
145        if let Ok(val) = std::env::var("NVIDIA_VISIBLE_DEVICES") {
146            cmd.arg("-e").arg(format!("NVIDIA_VISIBLE_DEVICES={val}"));
147        }
148
149        cmd.arg(spec.image.docker_arg());
150
151        for arg in &spec.command {
152            cmd.arg(arg);
153        }
154
155        if spec.capture_output {
156            cmd.stdin(Stdio::null())
157                .stdout(Stdio::piped())
158                .stderr(Stdio::piped());
159            let output = cmd
160                .output()
161                .map_err(|e| BvError::RuntimeError(format!("docker run failed to launch: {e}")))?;
162            return Ok(RunOutcome {
163                exit_code: output.status.code().unwrap_or(-1),
164                duration: start.elapsed(),
165                stdout: output.stdout,
166                stderr: output.stderr,
167            });
168        }
169
170        cmd.stdin(Stdio::inherit())
171            .stdout(Stdio::inherit())
172            .stderr(Stdio::inherit());
173
174        let status = cmd
175            .status()
176            .map_err(|e| BvError::RuntimeError(format!("docker run failed to launch: {e}")))?;
177
178        Ok(RunOutcome {
179            exit_code: status.code().unwrap_or(-1),
180            duration: start.elapsed(),
181            stdout: Vec::new(),
182            stderr: Vec::new(),
183        })
184    }
185
186    fn inspect(&self, digest: &ImageDigest) -> Result<ImageMetadata> {
187        let output = Command::new("docker")
188            .args(["image", "inspect", "--format", "{{.Size}}", &digest.0])
189            .output()
190            .map_err(|e| BvError::RuntimeError(e.to_string()))?;
191
192        if !output.status.success() {
193            return Err(BvError::RuntimeError(format!(
194                "docker image inspect failed for '{}'",
195                digest.0
196            )));
197        }
198
199        let size_bytes = String::from_utf8_lossy(&output.stdout)
200            .trim()
201            .parse::<u64>()
202            .ok();
203
204        Ok(ImageMetadata {
205            digest: digest.clone(),
206            size_bytes,
207            labels: HashMap::new(),
208        })
209    }
210
211    fn is_locally_available(&self, image_ref: &str, digest: &str) -> bool {
212        let pinned = format!("{image_ref}@{digest}");
213        Command::new("docker")
214            .args(["image", "inspect", "--format", "{{.Id}}", &pinned])
215            .stdout(Stdio::null())
216            .stderr(Stdio::null())
217            .status()
218            .map(|s| s.success())
219            .unwrap_or(false)
220    }
221
222    fn gpu_args(&self, profile: &GpuProfile) -> Vec<String> {
223        match &profile.spec {
224            Some(spec) if spec.required => vec!["--gpus".into(), "all".into()],
225            _ => vec![],
226        }
227    }
228
229    fn mount_args(&self, mounts: &[Mount]) -> Vec<String> {
230        mounts
231            .iter()
232            .flat_map(|m| {
233                let mode = if m.read_only { "ro" } else { "rw" };
234                let spec = format!(
235                    "{}:{}:{mode}",
236                    m.host_path.display(),
237                    m.container_path.display()
238                );
239                ["-v".to_string(), spec]
240            })
241            .collect()
242    }
243}
244
245impl DockerRuntime {
246    /// Pull `image` and bail if the registry-reported digest does not match
247    /// `expected_digest`.
248    ///
249    /// `pull` itself does not enforce this: it returns whatever digest the
250    /// registry hands back. Most callers (`bv sync`) already cross-check
251    /// against the lockfile, but the `bv run` and `bv conform` paths short
252    /// circuit through `is_locally_available`, which only proves that a
253    /// matching RepoDigests entry exists in the local cache, not that the
254    /// upstream image still resolves to the pinned sha. New code that
255    /// requires a digest pin should call this method instead of `pull`.
256    //
257    // TODO: route `bv run` / `bv conform` through this once the
258    // `ContainerRuntime` trait gains a `pull_verified` method (would
259    // touch bv-cli/runtime_select and the apptainer impl, so deferred).
260    pub fn pull_verified(
261        &self,
262        image: &OciRef,
263        expected_digest: &str,
264        progress: &dyn ProgressReporter,
265    ) -> Result<ImageDigest> {
266        let got = self.pull(image, progress)?;
267        verify_digest(&image.to_string(), expected_digest, &got.0)?;
268        Ok(got)
269    }
270
271    /// Pull `image`, verify the image digest, then verify each per-layer
272    /// digest from `layers` against what Docker reports for the pulled image.
273    ///
274    /// Callers that hold a `LockfileEntry` with `spec_kind = FactoredOci`
275    /// should call this instead of `pull_verified` so that individual
276    /// conda-package layer tampering is caught immediately after pull.
277    ///
278    /// Error messages include the expected and actual digest plus the layer
279    /// position so that upstream tampering is easy to diagnose.
280    pub fn pull_verified_v2(
281        &self,
282        image: &OciRef,
283        expected_image_digest: &str,
284        layers: &[bv_core::lockfile::LayerDescriptor],
285        progress: &dyn ProgressReporter,
286    ) -> Result<ImageDigest> {
287        let got = self.pull(image, progress)?;
288        verify_digest(&image.to_string(), expected_image_digest, &got.0)?;
289
290        if !layers.is_empty() {
291            self.verify_layer_digests(image, layers)?;
292        }
293
294        Ok(got)
295    }
296
297    /// Verify per-layer digests for an already-pulled image.
298    ///
299    /// Uses `docker manifest inspect` (or `docker image inspect`) to obtain the
300    /// layer list and cross-checks each digest against the lockfile entry.
301    /// On mismatch, the error message names the layer index, the expected digest,
302    /// and the actual digest to make upstream tampering easy to diagnose.
303    pub fn verify_layer_digests(
304        &self,
305        image: &OciRef,
306        expected_layers: &[bv_core::lockfile::LayerDescriptor],
307    ) -> Result<()> {
308        let image_arg = image.docker_arg();
309
310        // `docker image inspect` returns a JSON array; we extract the RootFS layers.
311        let output = Command::new("docker")
312            .args([
313                "image",
314                "inspect",
315                "--format",
316                "{{range .RootFS.Layers}}{{.}}\n{{end}}",
317                &image_arg,
318            ])
319            .output()
320            .map_err(|e| BvError::RuntimeError(format!("docker image inspect failed: {e}")))?;
321
322        if !output.status.success() {
323            // Image may not be locally available; layer verification is best-effort here.
324            return Ok(());
325        }
326
327        let stdout = String::from_utf8_lossy(&output.stdout);
328        let actual_layers: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
329
330        // Docker's RootFS digest and the OCI manifest digest use different algorithms
331        // in some cases (DiffID vs compressed digest).  We do an exact-match check when
332        // the layer counts agree, and log a warning when they differ (e.g. when the image
333        // was built with a non-standard compressor).
334        if actual_layers.len() != expected_layers.len() {
335            // Layer count mismatch is non-fatal for legacy images that were not
336            // built by bv-builder; for factored_oci images built by bv-builder
337            // this will be caught at manifest inspection time.
338            return Ok(());
339        }
340
341        for (i, (expected, actual)) in expected_layers.iter().zip(actual_layers.iter()).enumerate()
342        {
343            if expected.digest != *actual {
344                return Err(BvError::RuntimeError(format!(
345                    "layer digest mismatch at index {i} for '{image_arg}': \
346                     expected {expected_digest} but got {actual} — \
347                     possible upstream tampering or mismatched layer ordering",
348                    expected_digest = expected.digest,
349                )));
350            }
351        }
352
353        Ok(())
354    }
355
356    /// Get the content digest for a locally available image by reference.
357    fn repo_digest(&self, image_ref: &str) -> Result<String> {
358        let output = Command::new("docker")
359            .args([
360                "image",
361                "inspect",
362                "--format",
363                "{{index .RepoDigests 0}}",
364                image_ref,
365            ])
366            .output()
367            .map_err(|e| BvError::RuntimeError(e.to_string()))?;
368
369        if !output.status.success() {
370            return Err(BvError::RuntimeError(format!(
371                "could not inspect image '{image_ref}' after pull"
372            )));
373        }
374
375        let line = String::from_utf8_lossy(&output.stdout);
376        let line = line.trim();
377
378        // RepoDigests entry looks like "ncbi/blast@sha256:abc123"; extract digest part.
379        if let Some(digest) = line.split('@').nth(1) {
380            Ok(digest.to_string())
381        } else if line.starts_with("sha256:") {
382            Ok(line.to_string())
383        } else {
384            // Locally built image without a registry digest; fall back to image ID.
385            let id_output = Command::new("docker")
386                .args(["image", "inspect", "--format", "{{.Id}}", image_ref])
387                .output()
388                .map_err(|e| BvError::RuntimeError(e.to_string()))?;
389            Ok(String::from_utf8_lossy(&id_output.stdout)
390                .trim()
391                .to_string())
392        }
393    }
394}
395
396/// Return the calling process's effective uid:gid on Unix.
397///
398/// Falls back to `None` on non-Unix targets (Windows) so callers can skip
399/// passing `--user` rather than guessing.
400#[cfg(unix)]
401fn current_uid_gid() -> Option<(u32, u32)> {
402    // SAFETY: getuid()/getgid() are async-signal-safe and never fail per POSIX.
403    unsafe extern "C" {
404        fn getuid() -> u32;
405        fn getgid() -> u32;
406    }
407    Some((unsafe { getuid() }, unsafe { getgid() }))
408}
409
410#[cfg(not(unix))]
411fn current_uid_gid() -> Option<(u32, u32)> {
412    None
413}
414
415/// Compare a registry-returned digest to the digest the caller pinned and
416/// return a clear error on mismatch.
417fn verify_digest(image_ref: &str, expected: &str, got: &str) -> Result<()> {
418    if expected == got {
419        Ok(())
420    } else {
421        Err(BvError::RuntimeError(format!(
422            "image digest mismatch for '{image_ref}': expected {expected} but registry returned {got}"
423        )))
424    }
425}
426
427/// Map docker pull stderr to a user-friendly `BvError`.
428fn classify_pull_error(stderr: &str, image_ref: &str) -> BvError {
429    if stderr.contains("Cannot connect to the Docker daemon")
430        || stderr.contains("Is the docker daemon running")
431    {
432        BvError::RuntimeNotAvailable {
433            runtime: "docker".into(),
434            reason: "Docker daemon is not available. Is Docker Desktop running?".into(),
435        }
436    } else if stderr.contains("manifest unknown")
437        || stderr.contains("not found")
438        || stderr.contains("does not exist")
439    {
440        BvError::RuntimeError(format!(
441            "image '{image_ref}' not found in registry (check the tool manifest)"
442        ))
443    } else if stderr.contains("connection refused") || stderr.contains("no such host") {
444        BvError::RuntimeError(format!(
445            "network error while pulling '{image_ref}': {stderr}"
446        ))
447    } else {
448        BvError::RuntimeError(format!("docker pull failed:\n{stderr}"))
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn verify_digest_matches() {
458        assert!(verify_digest("ncbi/blast", "sha256:abc", "sha256:abc").is_ok());
459    }
460
461    #[test]
462    fn verify_digest_rejects_mismatch() {
463        let err = verify_digest("ncbi/blast", "sha256:abc", "sha256:def").unwrap_err();
464        let msg = err.to_string();
465        assert!(msg.contains("ncbi/blast"));
466        assert!(msg.contains("sha256:abc"));
467        assert!(msg.contains("sha256:def"));
468        assert!(msg.contains("mismatch"));
469    }
470
471    #[cfg(unix)]
472    #[test]
473    fn current_uid_gid_returns_some_on_unix() {
474        let got = current_uid_gid();
475        assert!(got.is_some());
476    }
477}