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 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 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 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 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(""); 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 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 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 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 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 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 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 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 if actual_layers.len() != expected_layers.len() {
335 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 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 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 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#[cfg(unix)]
401fn current_uid_gid() -> Option<(u32, u32)> {
402 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
415fn 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
427fn 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}