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#[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 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 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#[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 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 #[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 pub stdout: Vec<u8>,
185 pub stderr: Vec<u8>,
187}
188
189pub trait ProgressReporter: Send + Sync {
192 fn update(&self, message: &str, current: Option<u64>, total: Option<u64>);
193 fn finish(&self, message: &str);
194
195 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#[derive(Debug, Clone)]
219pub struct LayerSpec {
220 pub digest: String,
221 pub size: u64,
222 pub media_type: String,
223 pub blob_url: Option<String>,
225}
226
227#[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 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 fn ensure_layers(
256 &self,
257 _layers: &[LayerSpec],
258 _progress: &dyn ProgressReporter,
259 ) -> Result<()> {
260 Ok(())
261 }
262
263 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#[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}