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. We accept two
35/// signals as "alive": exit code 0, OR substantial output to stdout/stderr.
36/// The latter catches tools that follow the Unix convention of "print help,
37/// exit non-zero" for unknown args (bwa, seqtk, fasttree). A binary that
38/// segfaulted on load would produce neither, so this is a safe relaxation.
39const DEFAULT_PROBES: &[&str] = &["--version", "-version", "--help", "-h", "-v", "version", ""];
40
41/// Minimum bytes of stdout+stderr to count a probe as "produced output".
42/// Tuned to filter out noise like a single newline or a one-line "command not
43/// found" while accepting any real help/version blurb (typically >100 bytes).
44const ALIVE_OUTPUT_THRESHOLD: usize = 30;
45
46/// Run the smoke check for a manifest using the given runtime.
47///
48/// For every binary in `[tool.binaries]` (or the entrypoint command for
49/// single-binary tools), try a small set of probe args. A binary passes
50/// if any probe exits 0. Tool authors can override the probe list per
51/// binary, or skip individual binaries, via `[tool.smoke]`.
52///
53/// Returns `Ok(result)` even on conformance failures; `Err` only on setup
54/// errors (e.g. tempdir creation).
55pub fn run(
56    manifest: &Manifest,
57    image_digest: &str,
58    runtime: &dyn ContainerRuntime,
59) -> anyhow::Result<ConformanceResult> {
60    let tool = &manifest.tool;
61    let start = std::time::Instant::now();
62
63    let failures = check_binaries(manifest, image_digest, runtime);
64
65    let duration = start.elapsed();
66    if failures.is_empty() {
67        Ok(ConformanceResult::pass(
68            &tool.id,
69            vec!["all binaries responded to smoke probes".into()],
70            duration,
71        ))
72    } else {
73        Ok(ConformanceResult::fail(&tool.id, failures, duration))
74    }
75}
76
77/// For each binary, try probes until one is accepted. A probe is accepted
78/// if the binary exits 0 OR produces ≥ALIVE_OUTPUT_THRESHOLD bytes on
79/// stdout/stderr. Manifest-declared `[tool.smoke]` overrides take precedence:
80/// a `probes` entry pins the probe to one specific arg, and a `skip` entry
81/// omits the binary entirely.
82fn check_binaries(
83    manifest: &Manifest,
84    image_digest: &str,
85    runtime: &dyn ContainerRuntime,
86) -> Vec<String> {
87    let tool = &manifest.tool;
88    let binaries = tool.effective_binaries();
89
90    let mut image: OciRef = match tool.image.reference.parse() {
91        Ok(r) => r,
92        Err(_) => return vec!["invalid image reference; cannot run smoke check".into()],
93    };
94    image.tag = None;
95    image.digest = Some(image_digest.to_string());
96
97    let tmp = match tempfile::TempDir::new() {
98        Ok(t) => t,
99        Err(e) => return vec![format!("failed to create temp workspace: {e}")],
100    };
101
102    let smoke = tool.smoke.clone().unwrap_or_default();
103    let mut failures = Vec::new();
104
105    for binary in binaries {
106        if smoke.skip.iter().any(|s| s == binary) {
107            continue;
108        }
109
110        // If the manifest pins a probe for this binary, only try that one.
111        // Otherwise try the default list and accept the first exit 0.
112        let probes: Vec<String> = match smoke.probes.get(binary) {
113            Some(probe) => vec![probe.clone()],
114            None => DEFAULT_PROBES.iter().map(|s| s.to_string()).collect(),
115        };
116
117        let mut passed = false;
118        for probe in &probes {
119            let mut command = vec![binary.to_string()];
120            if !probe.is_empty() {
121                command.push(probe.clone());
122            }
123            let spec = RunSpec {
124                image: image.clone(),
125                command,
126                env: Default::default(),
127                mounts: vec![Mount {
128                    host_path: tmp.path().to_path_buf(),
129                    container_path: PathBuf::from("/workspace"),
130                    read_only: false,
131                }],
132                gpu: GpuProfile { spec: None },
133                working_dir: Some(PathBuf::from("/workspace")),
134                capture_output: true,
135            };
136            if let Ok(outcome) = runtime.run(&spec)
137                && (outcome.exit_code == 0
138                    || outcome.stdout.len() + outcome.stderr.len() >= ALIVE_OUTPUT_THRESHOLD)
139            {
140                passed = true;
141                break;
142            }
143        }
144
145        if !passed {
146            let probe_list = probes
147                .iter()
148                .map(|p| {
149                    if p.is_empty() {
150                        "(no args)".into()
151                    } else {
152                        format!("'{p}'")
153                    }
154                })
155                .collect::<Vec<_>>()
156                .join(" / ");
157            failures.push(format!(
158                "binary '{binary}' did not respond to {probe_list}.\n  \
159                 If this is expected (no version/help arg), add it to [tool.smoke].skip\n  \
160                 in the manifest. Or pin the right probe via [tool.smoke].probes."
161            ));
162        }
163    }
164    failures
165}
166
167/// Pull the image and verify it is reachable. Returns the digest.
168pub fn verify_image_reachable(
169    manifest: &Manifest,
170    runtime: &dyn ContainerRuntime,
171) -> anyhow::Result<ImageDigest> {
172    let oci_ref: OciRef = manifest
173        .tool
174        .image
175        .reference
176        .parse()
177        .map_err(|e| anyhow::anyhow!("invalid image ref: {e}"))?;
178
179    let reporter = bv_runtime::NoopProgress;
180    runtime
181        .pull(&oci_ref, &reporter)
182        .with_context(|| format!("failed to pull image for '{}'", manifest.tool.id))
183}