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(wd) = &spec.working_dir {
122 cmd.args(["-w", &wd.to_string_lossy()]);
123 }
124
125 for arg in self.mount_args(&spec.mounts) {
126 cmd.arg(arg);
127 }
128
129 for (k, v) in &spec.env {
130 cmd.arg("-e").arg(format!("{k}={v}"));
131 }
132
133 for arg in self.gpu_args(&spec.gpu) {
134 cmd.arg(arg);
135 }
136
137 if let Ok(val) = std::env::var("NVIDIA_VISIBLE_DEVICES") {
139 cmd.arg("-e").arg(format!("NVIDIA_VISIBLE_DEVICES={val}"));
140 }
141
142 cmd.arg(spec.image.docker_arg());
143
144 for arg in &spec.command {
145 cmd.arg(arg);
146 }
147
148 if spec.capture_output {
149 cmd.stdin(Stdio::null())
150 .stdout(Stdio::piped())
151 .stderr(Stdio::piped());
152 let output = cmd
153 .output()
154 .map_err(|e| BvError::RuntimeError(format!("docker run failed to launch: {e}")))?;
155 return Ok(RunOutcome {
156 exit_code: output.status.code().unwrap_or(-1),
157 duration: start.elapsed(),
158 stdout: output.stdout,
159 stderr: output.stderr,
160 });
161 }
162
163 cmd.stdin(Stdio::inherit())
164 .stdout(Stdio::inherit())
165 .stderr(Stdio::inherit());
166
167 let status = cmd
168 .status()
169 .map_err(|e| BvError::RuntimeError(format!("docker run failed to launch: {e}")))?;
170
171 Ok(RunOutcome {
172 exit_code: status.code().unwrap_or(-1),
173 duration: start.elapsed(),
174 stdout: Vec::new(),
175 stderr: Vec::new(),
176 })
177 }
178
179 fn inspect(&self, digest: &ImageDigest) -> Result<ImageMetadata> {
180 let output = Command::new("docker")
181 .args(["image", "inspect", "--format", "{{.Size}}", &digest.0])
182 .output()
183 .map_err(|e| BvError::RuntimeError(e.to_string()))?;
184
185 if !output.status.success() {
186 return Err(BvError::RuntimeError(format!(
187 "docker image inspect failed for '{}'",
188 digest.0
189 )));
190 }
191
192 let size_bytes = String::from_utf8_lossy(&output.stdout)
193 .trim()
194 .parse::<u64>()
195 .ok();
196
197 Ok(ImageMetadata {
198 digest: digest.clone(),
199 size_bytes,
200 labels: HashMap::new(),
201 })
202 }
203
204 fn is_locally_available(&self, image_ref: &str, digest: &str) -> bool {
205 let pinned = format!("{image_ref}@{digest}");
206 Command::new("docker")
207 .args(["image", "inspect", "--format", "{{.Id}}", &pinned])
208 .stdout(Stdio::null())
209 .stderr(Stdio::null())
210 .status()
211 .map(|s| s.success())
212 .unwrap_or(false)
213 }
214
215 fn gpu_args(&self, profile: &GpuProfile) -> Vec<String> {
216 match &profile.spec {
217 Some(spec) if spec.required => vec!["--gpus".into(), "all".into()],
218 _ => vec![],
219 }
220 }
221
222 fn mount_args(&self, mounts: &[Mount]) -> Vec<String> {
223 mounts
224 .iter()
225 .flat_map(|m| {
226 let mode = if m.read_only { "ro" } else { "rw" };
227 let spec = format!(
228 "{}:{}:{mode}",
229 m.host_path.display(),
230 m.container_path.display()
231 );
232 ["-v".to_string(), spec]
233 })
234 .collect()
235 }
236}
237
238impl DockerRuntime {
239 fn repo_digest(&self, image_ref: &str) -> Result<String> {
241 let output = Command::new("docker")
242 .args([
243 "image",
244 "inspect",
245 "--format",
246 "{{index .RepoDigests 0}}",
247 image_ref,
248 ])
249 .output()
250 .map_err(|e| BvError::RuntimeError(e.to_string()))?;
251
252 if !output.status.success() {
253 return Err(BvError::RuntimeError(format!(
254 "could not inspect image '{image_ref}' after pull"
255 )));
256 }
257
258 let line = String::from_utf8_lossy(&output.stdout);
259 let line = line.trim();
260
261 if let Some(digest) = line.split('@').nth(1) {
263 Ok(digest.to_string())
264 } else if line.starts_with("sha256:") {
265 Ok(line.to_string())
266 } else {
267 let id_output = Command::new("docker")
269 .args(["image", "inspect", "--format", "{{.Id}}", image_ref])
270 .output()
271 .map_err(|e| BvError::RuntimeError(e.to_string()))?;
272 Ok(String::from_utf8_lossy(&id_output.stdout)
273 .trim()
274 .to_string())
275 }
276 }
277}
278
279fn classify_pull_error(stderr: &str, image_ref: &str) -> BvError {
281 if stderr.contains("Cannot connect to the Docker daemon")
282 || stderr.contains("Is the docker daemon running")
283 {
284 BvError::RuntimeNotAvailable {
285 runtime: "docker".into(),
286 reason: "Docker daemon is not available. Is Docker Desktop running?".into(),
287 }
288 } else if stderr.contains("manifest unknown")
289 || stderr.contains("not found")
290 || stderr.contains("does not exist")
291 {
292 BvError::RuntimeError(format!(
293 "image '{image_ref}' not found in registry (check the tool manifest)"
294 ))
295 } else if stderr.contains("connection refused") || stderr.contains("no such host") {
296 BvError::RuntimeError(format!(
297 "network error while pulling '{image_ref}': {stderr}"
298 ))
299 } else {
300 BvError::RuntimeError(format!("docker pull failed:\n{stderr}"))
301 }
302}