Skip to main content

bv_conformance/
runner.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use anyhow::Context;
5use bv_core::manifest::Manifest;
6use bv_runtime::{ContainerRuntime, GpuProfile, ImageDigest, Mount, OciRef, RunSpec};
7
8pub struct ConformanceResult {
9    pub tool_id: String,
10    pub passed: bool,
11    pub messages: Vec<String>,
12    pub duration: Duration,
13}
14
15impl ConformanceResult {
16    fn pass(tool_id: impl Into<String>, messages: Vec<String>, duration: Duration) -> Self {
17        Self {
18            tool_id: tool_id.into(),
19            passed: true,
20            messages,
21            duration,
22        }
23    }
24    fn fail(tool_id: impl Into<String>, messages: Vec<String>, duration: Duration) -> Self {
25        Self {
26            tool_id: tool_id.into(),
27            passed: false,
28            messages,
29            duration,
30        }
31    }
32}
33
34/// Probe args we try, in order, when smoke-checking a binary.
35/// First one to exit 0 wins. Tools have wildly inconsistent conventions
36/// (BLAST uses `-version`, samtools uses `--version`, samtools subcommands
37/// take `version`...), so we cast a wide net.
38const DEFAULT_PROBES: &[&str] = &["--version", "-version", "--help", "-h", "-v", "version"];
39
40/// Run the smoke check for a manifest using the given runtime.
41///
42/// For every binary in `[tool.binaries]` (or the entrypoint command for
43/// single-binary tools), try a small set of probe args. A binary passes
44/// if any probe exits 0. Tool authors can override the probe list per
45/// binary, or skip individual binaries, via `[tool.smoke]`.
46///
47/// Returns `Ok(result)` even on conformance failures; `Err` only on setup
48/// errors (e.g. tempdir creation).
49pub fn run(
50    manifest: &Manifest,
51    image_digest: &str,
52    runtime: &dyn ContainerRuntime,
53) -> anyhow::Result<ConformanceResult> {
54    let tool = &manifest.tool;
55    let start = std::time::Instant::now();
56
57    let failures = check_binaries(manifest, image_digest, runtime);
58
59    let duration = start.elapsed();
60    if failures.is_empty() {
61        Ok(ConformanceResult::pass(
62            &tool.id,
63            vec!["all binaries responded to smoke probes".into()],
64            duration,
65        ))
66    } else {
67        Ok(ConformanceResult::fail(&tool.id, failures, duration))
68    }
69}
70
71/// For each binary, try probes until one exits 0. Manifest-declared
72/// `[tool.smoke]` overrides take precedence: a `probes` entry pins the
73/// probe to one specific arg, and a `skip` entry omits the binary entirely.
74fn check_binaries(
75    manifest: &Manifest,
76    image_digest: &str,
77    runtime: &dyn ContainerRuntime,
78) -> Vec<String> {
79    let tool = &manifest.tool;
80    let binaries = tool.effective_binaries();
81
82    let mut image: OciRef = match tool.image.reference.parse() {
83        Ok(r) => r,
84        Err(_) => return vec!["invalid image reference; cannot run smoke check".into()],
85    };
86    image.tag = None;
87    image.digest = Some(image_digest.to_string());
88
89    let tmp = match tempfile::TempDir::new() {
90        Ok(t) => t,
91        Err(e) => return vec![format!("failed to create temp workspace: {e}")],
92    };
93
94    let smoke = tool.smoke.clone().unwrap_or_default();
95    let mut failures = Vec::new();
96
97    for binary in binaries {
98        if smoke.skip.iter().any(|s| s == binary) {
99            continue;
100        }
101
102        // If the manifest pins a probe for this binary, only try that one.
103        // Otherwise try the default list and accept the first exit 0.
104        let probes: Vec<String> = match smoke.probes.get(binary) {
105            Some(probe) => vec![probe.clone()],
106            None => DEFAULT_PROBES.iter().map(|s| s.to_string()).collect(),
107        };
108
109        let mut passed = false;
110        for probe in &probes {
111            let mut command = vec![binary.to_string()];
112            if !probe.is_empty() {
113                command.push(probe.clone());
114            }
115            let spec = RunSpec {
116                image: image.clone(),
117                command,
118                env: Default::default(),
119                mounts: vec![Mount {
120                    host_path: tmp.path().to_path_buf(),
121                    container_path: PathBuf::from("/workspace"),
122                    read_only: false,
123                }],
124                gpu: GpuProfile { spec: None },
125                working_dir: Some(PathBuf::from("/workspace")),
126            };
127            if let Ok(outcome) = runtime.run(&spec)
128                && outcome.exit_code == 0
129            {
130                passed = true;
131                break;
132            }
133        }
134
135        if !passed {
136            let probe_list = probes
137                .iter()
138                .map(|p| {
139                    if p.is_empty() {
140                        "(no args)".into()
141                    } else {
142                        format!("'{p}'")
143                    }
144                })
145                .collect::<Vec<_>>()
146                .join(" / ");
147            failures.push(format!(
148                "binary '{binary}' did not respond to {probe_list}.\n  \
149                 If this is expected (no version/help arg), add it to [tool.smoke].skip\n  \
150                 in the manifest. Or pin the right probe via [tool.smoke].probes."
151            ));
152        }
153    }
154    failures
155}
156
157/// Pull the image and verify it is reachable. Returns the digest.
158pub fn verify_image_reachable(
159    manifest: &Manifest,
160    runtime: &dyn ContainerRuntime,
161) -> anyhow::Result<ImageDigest> {
162    let oci_ref: OciRef = manifest
163        .tool
164        .image
165        .reference
166        .parse()
167        .map_err(|e| anyhow::anyhow!("invalid image ref: {e}"))?;
168
169    let reporter = bv_runtime::NoopProgress;
170    runtime
171        .pull(&oci_ref, &reporter)
172        .with_context(|| format!("failed to pull image for '{}'", manifest.tool.id))
173}