Skip to main content

bv_conformance/
runner.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use anyhow::Context;
5use bv_core::manifest::{Manifest, TestSpec};
6use bv_runtime::{ContainerRuntime, GpuProfile, ImageDigest, Mount, OciRef, RunSpec};
7use tempfile::TempDir;
8
9use crate::{assertions, inputs};
10
11pub struct ConformanceResult {
12    pub tool_id: String,
13    pub passed: bool,
14    pub messages: Vec<String>,
15    pub duration: Duration,
16}
17
18impl ConformanceResult {
19    fn pass(tool_id: impl Into<String>, messages: Vec<String>, duration: Duration) -> Self {
20        Self {
21            tool_id: tool_id.into(),
22            passed: true,
23            messages,
24            duration,
25        }
26    }
27    fn fail(tool_id: impl Into<String>, messages: Vec<String>, duration: Duration) -> Self {
28        Self {
29            tool_id: tool_id.into(),
30            passed: false,
31            messages,
32            duration,
33        }
34    }
35}
36
37/// Run the conformance test for a manifest using the given runtime.
38/// Returns `Ok(result)` even on conformance failures; `Err` means a setup
39/// error (e.g. the image could not be pulled at all).
40pub fn run(
41    manifest: &Manifest,
42    image_digest: &str,
43    runtime: &dyn ContainerRuntime,
44) -> anyhow::Result<ConformanceResult> {
45    let tool = &manifest.tool;
46    let start = std::time::Instant::now();
47
48    let test_spec: &TestSpec = match &tool.test {
49        Some(t) => t,
50        None => {
51            return Ok(ConformanceResult::pass(
52                &tool.id,
53                vec!["no [tool.test] block; skipping".into()],
54                start.elapsed(),
55            ));
56        }
57    };
58
59    let tmp = TempDir::new().context("failed to create temp workspace")?;
60    let workspace = tmp.path();
61
62    // Materialize test inputs.
63    let input_paths = inputs::materialize_all(&test_spec.inputs, workspace)
64        .context("failed to materialize test inputs")?;
65
66    // Build the run spec.
67    let mut image: OciRef = tool
68        .image
69        .reference
70        .parse()
71        .map_err(|e| anyhow::anyhow!("invalid image ref: {e}"))?;
72    image.tag = None;
73    image.digest = Some(image_digest.to_string());
74
75    let mut command = vec![tool.entrypoint.command.clone()];
76    command.extend(test_spec.extra_args.iter().cloned());
77
78    // Substitute {port_name} placeholders with container paths.
79    let command = substitute_placeholders(
80        command,
81        &tool.entrypoint.args_template,
82        &input_paths,
83        &tool.outputs,
84        workspace,
85    );
86
87    let spec = RunSpec {
88        image,
89        command,
90        env: tool.entrypoint.env.clone(),
91        mounts: vec![Mount {
92            host_path: workspace.to_path_buf(),
93            container_path: PathBuf::from("/workspace"),
94            read_only: false,
95        }],
96        gpu: GpuProfile {
97            spec: tool.hardware.gpu.clone(),
98        },
99        working_dir: Some(PathBuf::from("/workspace")),
100    };
101
102    let outcome = runtime
103        .run(&spec)
104        .with_context(|| format!("failed to run '{}' during conformance test", tool.id))?;
105
106    if outcome.exit_code != 0 {
107        return Ok(ConformanceResult::fail(
108            &tool.id,
109            vec![format!("tool exited with code {}", outcome.exit_code)],
110            start.elapsed(),
111        ));
112    }
113
114    // Check declared outputs.
115    let mut failures = Vec::new();
116    for output_name in &test_spec.expected_outputs {
117        let spec = tool.outputs.iter().find(|o| &o.name == output_name);
118        let Some(output_spec) = spec else {
119            failures.push(format!(
120                "output '{}' declared in test block but not in [[tool.outputs]]",
121                output_name
122            ));
123            continue;
124        };
125
126        let output_path = output_spec
127            .mount
128            .as_deref()
129            .map(|m| workspace.join(m.strip_prefix("/workspace/").unwrap_or(m)))
130            .unwrap_or_else(|| workspace.join(output_name));
131
132        if let Err(e) = assertions::check_output(output_spec, &output_path) {
133            failures.push(format!("{}: {e}", output_name));
134        }
135    }
136
137    // Check that every declared binary responds to --help / --version / -h.
138    let binary_failures = check_binaries(manifest, image_digest, runtime);
139    failures.extend(binary_failures);
140
141    let duration = start.elapsed();
142    if failures.is_empty() {
143        Ok(ConformanceResult::pass(
144            &tool.id,
145            vec!["all checks passed".into()],
146            duration,
147        ))
148    } else {
149        Ok(ConformanceResult::fail(&tool.id, failures, duration))
150    }
151}
152
153/// For each binary in `tool.binaries.exposed`, verify it runs with
154/// `--help`, `--version`, or `-h` and exits 0.
155fn check_binaries(
156    manifest: &Manifest,
157    image_digest: &str,
158    runtime: &dyn ContainerRuntime,
159) -> Vec<String> {
160    let tool = &manifest.tool;
161    let binaries = tool.effective_binaries();
162
163    let mut image: OciRef = match tool.image.reference.parse() {
164        Ok(r) => r,
165        Err(_) => return vec![],
166    };
167    image.tag = None;
168    image.digest = Some(image_digest.to_string());
169
170    let tmp = match tempfile::TempDir::new() {
171        Ok(t) => t,
172        Err(_) => return vec![],
173    };
174
175    let mut failures = Vec::new();
176    for binary in binaries {
177        let mut passed = false;
178        for probe in &["--help", "--version", "-h"] {
179            let spec = RunSpec {
180                image: image.clone(),
181                command: vec![binary.to_string(), probe.to_string()],
182                env: Default::default(),
183                mounts: vec![Mount {
184                    host_path: tmp.path().to_path_buf(),
185                    container_path: PathBuf::from("/workspace"),
186                    read_only: false,
187                }],
188                gpu: GpuProfile { spec: None },
189                working_dir: Some(PathBuf::from("/workspace")),
190            };
191            if let Ok(outcome) = runtime.run(&spec)
192                && outcome.exit_code == 0
193            {
194                passed = true;
195                break;
196            }
197        }
198        if !passed {
199            failures.push(format!(
200                "binary '{binary}' did not respond to --help / --version / -h with exit 0"
201            ));
202        }
203    }
204    failures
205}
206
207fn substitute_placeholders(
208    mut command: Vec<String>,
209    args_template: &Option<String>,
210    input_paths: &std::collections::HashMap<String, PathBuf>,
211    outputs: &[bv_core::manifest::IoSpec],
212    workspace: &Path,
213) -> Vec<String> {
214    let Some(template) = args_template else {
215        return command;
216    };
217
218    let mut expanded = template.clone();
219
220    // Substitute input port placeholders: {port} → /workspace/<filename>
221    for (port, path) in input_paths {
222        let container_path = format!(
223            "/workspace/{}",
224            path.file_name().unwrap_or_default().to_string_lossy()
225        );
226        expanded = expanded.replace(&format!("{{{port}}}"), &container_path);
227    }
228
229    // Substitute output port placeholders: {port} → /workspace/<port_name>
230    // This must run before the generic {output} fallback below.
231    for output_spec in outputs {
232        let container_path = format!("/workspace/{}", output_spec.name);
233        expanded = expanded.replace(&format!("{{{}}}", output_spec.name), &container_path);
234    }
235
236    expanded = expanded.replace("{cpu_cores}", "1");
237    // Generic {output} fallback for manifests that don't name their output port.
238    expanded = expanded.replace(
239        "{output}",
240        &format!(
241            "/workspace/{}_output.txt",
242            command.first().map(|s| s.as_str()).unwrap_or("out")
243        ),
244    );
245
246    let _ = workspace;
247    let args: Vec<String> = expanded.split_whitespace().map(str::to_string).collect();
248    command.extend(args);
249    command
250}
251
252/// Pull the image and verify it is reachable. Returns the digest.
253pub fn verify_image_reachable(
254    manifest: &Manifest,
255    runtime: &dyn ContainerRuntime,
256) -> anyhow::Result<ImageDigest> {
257    let oci_ref: OciRef = manifest
258        .tool
259        .image
260        .reference
261        .parse()
262        .map_err(|e| anyhow::anyhow!("invalid image ref: {e}"))?;
263
264    let reporter = bv_runtime::NoopProgress;
265    runtime
266        .pull(&oci_ref, &reporter)
267        .with_context(|| format!("failed to pull image for '{}'", manifest.tool.id))
268}