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
34const DEFAULT_PROBES: &[&str] = &["--version", "-version", "--help", "-h", "-v", "version"];
39
40pub 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
71fn 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 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
157pub 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}