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", ""];
40
41const ALIVE_OUTPUT_THRESHOLD: usize = 30;
45
46pub 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
77fn 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 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 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
177pub 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}