Skip to main content

bv_runtime/
runtime.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::PathBuf;
4use std::str::FromStr;
5use std::time::Duration;
6
7use bv_core::error::Result;
8use bv_core::manifest::GpuSpec;
9
10// OciRef
11
12#[derive(Debug, Clone)]
13pub struct OciRef {
14    pub registry: String,
15    pub repository: String,
16    pub tag: Option<String>,
17    pub digest: Option<String>,
18}
19
20impl OciRef {
21    pub fn parse(s: &str) -> std::result::Result<Self, String> {
22        s.parse()
23    }
24
25    /// Return the string Docker expects for `docker pull` / `docker run`.
26    /// For docker.io images the registry prefix is stripped so that Docker Hub
27    /// resolves references correctly across all Docker versions.
28    pub fn docker_arg(&self) -> String {
29        if self.registry == "docker.io" {
30            let mut s = self.repository.clone();
31            if let Some(tag) = &self.tag {
32                s.push(':');
33                s.push_str(tag);
34            }
35            if let Some(digest) = &self.digest {
36                s.push('@');
37                s.push_str(digest);
38            }
39            s
40        } else {
41            self.to_string()
42        }
43    }
44}
45
46impl fmt::Display for OciRef {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "{}/{}", self.registry, self.repository)?;
49        if let Some(tag) = &self.tag {
50            write!(f, ":{tag}")?;
51        }
52        if let Some(digest) = &self.digest {
53            write!(f, "@{digest}")?;
54        }
55        Ok(())
56    }
57}
58
59impl FromStr for OciRef {
60    type Err = String;
61
62    fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
63        let s = raw
64            .strip_prefix("docker://")
65            .or_else(|| raw.strip_prefix("oci://"))
66            .unwrap_or(raw);
67
68        let (image_part, digest) = if let Some((img, d)) = s.split_once('@') {
69            (img, Some(d.to_string()))
70        } else {
71            (s, None)
72        };
73
74        // Tag detection rules:
75        // - If there is no '/', the input is a single image name with an
76        //   optional tag (e.g. `foo:1.0`).
77        // - Otherwise, only the segment after the last '/' may carry a tag.
78        //   This avoids treating a registry port (`localhost:5000/foo`) as a tag.
79        let (name_part, tag) = if !image_part.contains('/') {
80            if let Some(pos) = image_part.rfind(':') {
81                (&image_part[..pos], Some(image_part[pos + 1..].to_string()))
82            } else {
83                (image_part, None)
84            }
85        } else {
86            let last_slash = image_part.rfind('/').unwrap();
87            let last_segment = &image_part[last_slash + 1..];
88            if let Some(rel_colon) = last_segment.find(':') {
89                let split = last_slash + 1 + rel_colon;
90                (
91                    &image_part[..split],
92                    Some(image_part[split + 1..].to_string()),
93                )
94            } else {
95                (image_part, None)
96            }
97        };
98
99        let (registry, repository) = split_registry(name_part);
100
101        Ok(OciRef {
102            registry,
103            repository,
104            tag,
105            digest,
106        })
107    }
108}
109
110fn split_registry(name: &str) -> (String, String) {
111    if let Some(slash_pos) = name.find('/') {
112        let potential_registry = &name[..slash_pos];
113        if potential_registry.contains('.')
114            || potential_registry.contains(':')
115            || potential_registry == "localhost"
116        {
117            return (
118                potential_registry.to_string(),
119                name[slash_pos + 1..].to_string(),
120            );
121        }
122    }
123    ("docker.io".to_string(), name.to_string())
124}
125
126// Supporting types
127
128#[derive(Debug, Clone)]
129pub struct ImageDigest(pub String);
130
131impl fmt::Display for ImageDigest {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(&self.0)
134    }
135}
136
137#[derive(Debug, Clone)]
138pub struct ImageMetadata {
139    pub digest: ImageDigest,
140    pub size_bytes: Option<u64>,
141    pub labels: HashMap<String, String>,
142}
143
144#[derive(Debug, Clone)]
145pub struct RuntimeInfo {
146    pub name: String,
147    pub version: String,
148    pub extra: HashMap<String, String>,
149}
150
151#[derive(Debug, Clone)]
152pub struct Mount {
153    pub host_path: PathBuf,
154    pub container_path: PathBuf,
155    pub read_only: bool,
156}
157
158#[derive(Debug, Clone, Default)]
159pub struct GpuProfile {
160    pub spec: Option<GpuSpec>,
161}
162
163#[derive(Debug, Clone)]
164pub struct RunSpec {
165    /// OCI reference of the image to run (may carry a pinned digest).
166    pub image: OciRef,
167    pub command: Vec<String>,
168    pub env: HashMap<String, String>,
169    pub mounts: Vec<Mount>,
170    pub gpu: GpuProfile,
171    pub working_dir: Option<PathBuf>,
172    /// When true, capture stdout+stderr into `RunOutcome` instead of inheriting
173    /// to the host. Used by `bv conformance` to inspect probe output without
174    /// flooding the user's terminal.
175    #[doc(hidden)]
176    pub capture_output: bool,
177}
178
179#[derive(Debug, Default)]
180pub struct RunOutcome {
181    pub exit_code: i32,
182    pub duration: Duration,
183    /// Captured stdout when `RunSpec.capture_output` is true; empty otherwise.
184    pub stdout: Vec<u8>,
185    /// Captured stderr when `RunSpec.capture_output` is true; empty otherwise.
186    pub stderr: Vec<u8>,
187}
188
189// ProgressReporter
190
191pub trait ProgressReporter: Send + Sync {
192    fn update(&self, message: &str, current: Option<u64>, total: Option<u64>);
193    fn finish(&self, message: &str);
194
195    /// Hide our own spinner/bars while a child process draws to the terminal.
196    /// Implementations should clear their lines until the returned guard is dropped.
197    /// The default is a no-op for reporters that don't draw to a TTY.
198    fn pause(&self) -> Box<dyn PauseGuard + '_> {
199        Box::new(NoopPauseGuard)
200    }
201}
202
203pub trait PauseGuard {}
204
205pub struct NoopPauseGuard;
206impl PauseGuard for NoopPauseGuard {}
207
208pub struct NoopProgress;
209
210impl ProgressReporter for NoopProgress {
211    fn update(&self, _: &str, _: Option<u64>, _: Option<u64>) {}
212    fn finish(&self, _: &str) {}
213}
214
215// ContainerRuntime trait
216
217/// Descriptor for a single OCI layer that needs to be available locally.
218#[derive(Debug, Clone)]
219pub struct LayerSpec {
220    pub digest: String,
221    pub size: u64,
222    pub media_type: String,
223    /// Source URL for pulling the layer blob when not already cached.
224    pub blob_url: Option<String>,
225}
226
227/// A locally-available image identified by an OCI reference + digest.
228#[derive(Debug, Clone)]
229pub struct ImageRef {
230    pub reference: String,
231    pub digest: String,
232}
233
234pub trait ContainerRuntime {
235    fn name(&self) -> &str;
236    fn health_check(&self) -> Result<RuntimeInfo>;
237    fn pull(&self, image: &OciRef, progress: &dyn ProgressReporter) -> Result<ImageDigest>;
238    fn run(&self, spec: &RunSpec) -> Result<RunOutcome>;
239    fn inspect(&self, digest: &ImageDigest) -> Result<ImageMetadata>;
240    /// Check whether `image_ref@digest` is already in the local Docker cache.
241    fn is_locally_available(&self, _image_ref: &str, digest: &str) -> bool {
242        self.inspect(&ImageDigest(digest.to_string())).is_ok()
243    }
244    fn gpu_args(&self, profile: &GpuProfile) -> Vec<String>;
245    fn mount_args(&self, mounts: &[Mount]) -> Vec<String>;
246
247    /// Pull only the specified layers, deduplicating against the local cache.
248    ///
249    /// For `factored_oci` tools, callers pass the per-package layer list and
250    /// the runtime ensures each layer blob is present locally before
251    /// `assemble_image` is called.
252    ///
253    /// The default implementation is a no-op (runtimes that don't support
254    /// factored pulls fall back to `pull`).
255    fn ensure_layers(
256        &self,
257        _layers: &[LayerSpec],
258        _progress: &dyn ProgressReporter,
259    ) -> Result<()> {
260        Ok(())
261    }
262
263    /// Assemble a runnable image from a manifest whose layers are all locally
264    /// available (guaranteed by a preceding `ensure_layers` call).
265    ///
266    /// Returns a locally-addressable `ImageRef`. The default implementation
267    /// falls back to `pull` for runtimes that don't support layer assembly.
268    fn assemble_image(
269        &self,
270        image: &OciRef,
271        _layers: &[LayerSpec],
272        progress: &dyn ProgressReporter,
273    ) -> Result<ImageRef> {
274        let digest = self.pull(image, progress)?;
275        Ok(ImageRef {
276            reference: image.to_string(),
277            digest: digest.0,
278        })
279    }
280}
281
282// Tests
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn parse_simple_image() {
290        let r: OciRef = "ubuntu:22.04".parse().unwrap();
291        assert_eq!(r.registry, "docker.io");
292        assert_eq!(r.repository, "ubuntu");
293        assert_eq!(r.tag.as_deref(), Some("22.04"));
294        assert!(r.digest.is_none());
295    }
296
297    #[test]
298    fn parse_with_registry() {
299        let r: OciRef = "ghcr.io/biocontainers/bwa:0.7.17".parse().unwrap();
300        assert_eq!(r.registry, "ghcr.io");
301        assert_eq!(r.repository, "biocontainers/bwa");
302        assert_eq!(r.tag.as_deref(), Some("0.7.17"));
303    }
304
305    #[test]
306    fn parse_with_digest() {
307        let r: OciRef = "ubuntu@sha256:abc123".parse().unwrap();
308        assert_eq!(r.digest.as_deref(), Some("sha256:abc123"));
309        assert!(r.tag.is_none());
310    }
311
312    #[test]
313    fn parse_docker_scheme() {
314        let r: OciRef = "docker://biocontainers/bwa:0.7.17".parse().unwrap();
315        assert_eq!(r.registry, "docker.io");
316        assert_eq!(r.repository, "biocontainers/bwa");
317    }
318
319    #[test]
320    fn docker_arg_strips_docker_io() {
321        let r: OciRef = "ncbi/blast:2.14.0".parse().unwrap();
322        assert_eq!(r.docker_arg(), "ncbi/blast:2.14.0");
323
324        let mut r2: OciRef = "ncbi/blast:2.14.0".parse().unwrap();
325        r2.tag = None;
326        r2.digest = Some("sha256:abc123".into());
327        assert_eq!(r2.docker_arg(), "ncbi/blast@sha256:abc123");
328    }
329
330    #[test]
331    fn docker_arg_keeps_external_registry() {
332        let r: OciRef = "quay.io/biocontainers/blast:2.15.0".parse().unwrap();
333        assert_eq!(r.docker_arg(), "quay.io/biocontainers/blast:2.15.0");
334    }
335
336    #[test]
337    fn parse_localhost_port_registry_with_repo() {
338        let r: OciRef = "localhost:5000/foo/bar".parse().unwrap();
339        assert_eq!(r.registry, "localhost:5000");
340        assert_eq!(r.repository, "foo/bar");
341        assert!(r.tag.is_none());
342        assert!(r.digest.is_none());
343    }
344
345    #[test]
346    fn parse_localhost_port_registry_with_repo_and_tag() {
347        let r: OciRef = "localhost:5000/foo/bar:1.0".parse().unwrap();
348        assert_eq!(r.registry, "localhost:5000");
349        assert_eq!(r.repository, "foo/bar");
350        assert_eq!(r.tag.as_deref(), Some("1.0"));
351    }
352
353    #[test]
354    fn parse_single_name() {
355        let r: OciRef = "foo".parse().unwrap();
356        assert_eq!(r.registry, "docker.io");
357        assert_eq!(r.repository, "foo");
358        assert!(r.tag.is_none());
359    }
360
361    #[test]
362    fn parse_single_name_with_tag() {
363        let r: OciRef = "foo:1.0".parse().unwrap();
364        assert_eq!(r.registry, "docker.io");
365        assert_eq!(r.repository, "foo");
366        assert_eq!(r.tag.as_deref(), Some("1.0"));
367    }
368
369    #[test]
370    fn parse_two_segments_no_registry() {
371        let r: OciRef = "foo/bar".parse().unwrap();
372        assert_eq!(r.registry, "docker.io");
373        assert_eq!(r.repository, "foo/bar");
374        assert!(r.tag.is_none());
375    }
376
377    #[test]
378    fn parse_quay_with_tag() {
379        let r: OciRef = "quay.io/biocontainers/blast:2.14.0".parse().unwrap();
380        assert_eq!(r.registry, "quay.io");
381        assert_eq!(r.repository, "biocontainers/blast");
382        assert_eq!(r.tag.as_deref(), Some("2.14.0"));
383    }
384
385    #[test]
386    fn parse_ghcr_with_digest() {
387        let r: OciRef = "ghcr.io/owner/repo@sha256:abc".parse().unwrap();
388        assert_eq!(r.registry, "ghcr.io");
389        assert_eq!(r.repository, "owner/repo");
390        assert!(r.tag.is_none());
391        assert_eq!(r.digest.as_deref(), Some("sha256:abc"));
392    }
393}