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 for binary in binaries {
106 if smoke.skip.iter().any(|s| s == binary) {
107 continue;
108 }
109
110 let probes: Vec<String> = match smoke.probes.get(binary) {
113 Some(probe) => vec![probe.clone()],
114 None => DEFAULT_PROBES.iter().map(|s| s.to_string()).collect(),
115 };
116
117 let mut passed = false;
118 for probe in &probes {
119 let mut command = vec![binary.to_string()];
120 if !probe.is_empty() {
121 command.push(probe.clone());
122 }
123 let spec = RunSpec {
124 image: image.clone(),
125 command,
126 env: Default::default(),
127 mounts: vec![Mount {
128 host_path: tmp.path().to_path_buf(),
129 container_path: PathBuf::from("/workspace"),
130 read_only: false,
131 }],
132 gpu: GpuProfile { spec: None },
133 working_dir: Some(PathBuf::from("/workspace")),
134 capture_output: true,
135 };
136 if let Ok(outcome) = runtime.run(&spec)
137 && (outcome.exit_code == 0
138 || outcome.stdout.len() + outcome.stderr.len() >= ALIVE_OUTPUT_THRESHOLD)
139 {
140 passed = true;
141 break;
142 }
143 }
144
145 if !passed {
146 let probe_list = probes
147 .iter()
148 .map(|p| {
149 if p.is_empty() {
150 "(no args)".into()
151 } else {
152 format!("'{p}'")
153 }
154 })
155 .collect::<Vec<_>>()
156 .join(" / ");
157 failures.push(format!(
158 "binary '{binary}' did not respond to {probe_list}.\n \
159 If this is expected (no version/help arg), add it to [tool.smoke].skip\n \
160 in the manifest. Or pin the right probe via [tool.smoke].probes."
161 ));
162 }
163 }
164 failures
165}
166
167pub fn verify_image_reachable(
169 manifest: &Manifest,
170 runtime: &dyn ContainerRuntime,
171) -> anyhow::Result<ImageDigest> {
172 let oci_ref: OciRef = manifest
173 .tool
174 .image
175 .reference
176 .parse()
177 .map_err(|e| anyhow::anyhow!("invalid image ref: {e}"))?;
178
179 let reporter = bv_runtime::NoopProgress;
180 runtime
181 .pull(&oci_ref, &reporter)
182 .with_context(|| format!("failed to pull image for '{}'", manifest.tool.id))
183}