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    // Propagate the manifest's entrypoint.env to the probe so tools that need
106    // PATH / LD_LIBRARY_PATH set at startup don't false-fail. RunSpec.env is a
107    // HashMap; entrypoint.env is keyed by String so the iterate-and-clone is
108    // backend-agnostic (works for both HashMap and BTreeMap source types).
109    let entry_env: std::collections::HashMap<String, String> = tool
110        .entrypoint
111        .as_ref()
112        .map(|ep| ep.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
113        .unwrap_or_default();
114
115    for binary in binaries {
116        if smoke.skip.iter().any(|s| s == binary) {
117            continue;
118        }
119
120        // If the manifest pins a probe for this binary, only try that one.
121        // Otherwise try the default list and accept the first exit 0.
122        let probes: Vec<String> = match smoke.probes.get(binary) {
123            Some(probe) => vec![probe.clone()],
124            None => DEFAULT_PROBES.iter().map(|s| s.to_string()).collect(),
125        };
126
127        let mut passed = false;
128        for probe in &probes {
129            let mut command = vec![binary.to_string()];
130            if !probe.is_empty() {
131                command.push(probe.clone());
132            }
133            let spec = RunSpec {
134                image: image.clone(),
135                command,
136                env: entry_env.clone(),
137                mounts: vec![Mount {
138                    host_path: tmp.path().to_path_buf(),
139                    container_path: PathBuf::from("/workspace"),
140                    read_only: false,
141                }],
142                gpu: GpuProfile { spec: None },
143                working_dir: Some(PathBuf::from("/workspace")),
144                capture_output: true,
145            };
146            if let Ok(outcome) = runtime.run(&spec)
147                && (outcome.exit_code == 0
148                    || outcome.stdout.len() + outcome.stderr.len() >= ALIVE_OUTPUT_THRESHOLD)
149            {
150                passed = true;
151                break;
152            }
153        }
154
155        if !passed {
156            let probe_list = probes
157                .iter()
158                .map(|p| {
159                    if p.is_empty() {
160                        "(no args)".into()
161                    } else {
162                        format!("'{p}'")
163                    }
164                })
165                .collect::<Vec<_>>()
166                .join(" / ");
167            failures.push(format!(
168                "binary '{binary}' did not respond to {probe_list}.\n  \
169                 If this is expected (no version/help arg), add it to [tool.smoke].skip\n  \
170                 in the manifest. Or pin the right probe via [tool.smoke].probes."
171            ));
172        }
173    }
174    failures
175}
176
177/// Pull the image and verify it is reachable. Returns the digest.
178pub fn verify_image_reachable(
179    manifest: &Manifest,
180    runtime: &dyn ContainerRuntime,
181) -> anyhow::Result<ImageDigest> {
182    let oci_ref: OciRef = manifest
183        .tool
184        .image
185        .reference
186        .parse()
187        .map_err(|e| anyhow::anyhow!("invalid image ref: {e}"))?;
188
189    let reporter = bv_runtime::NoopProgress;
190    runtime
191        .pull(&oci_ref, &reporter)
192        .with_context(|| format!("failed to pull image for '{}'", manifest.tool.id))
193}