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
37pub 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 let input_paths = inputs::materialize_all(&test_spec.inputs, workspace)
64 .context("failed to materialize test inputs")?;
65
66 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 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 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 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
153fn 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 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 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 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
252pub 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}