Skip to content

Commit cd245e5

Browse files
committed
feat(campaign): persist typed build evaluation
1 parent e67f37c commit cd245e5

2 files changed

Lines changed: 372 additions & 0 deletions

File tree

‎src/campaign.rs‎

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
//! Persisted build-evaluation campaigns with typed failure findings.
2+
3+
use crate::target::{BuildTarget, CommandPlan, TargetError};
4+
use serde::{Deserialize, Serialize};
5+
use serde_json::Value;
6+
use std::path::{Path, PathBuf};
7+
use thiserror::Error;
8+
9+
pub const CAMPAIGN_SCHEMA_VERSION: u32 = 1;
10+
11+
#[derive(Debug, Clone)]
12+
pub struct CampaignRequest {
13+
pub bundle: PathBuf,
14+
pub target: BuildTarget,
15+
pub state_path: PathBuf,
16+
}
17+
18+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19+
#[serde(rename_all = "kebab-case")]
20+
pub enum CampaignStatus {
21+
Planned,
22+
Running,
23+
Failed,
24+
Completed,
25+
}
26+
27+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28+
#[serde(rename_all = "kebab-case")]
29+
pub enum BuildFindingClass {
30+
Transport,
31+
Executor,
32+
Runtime,
33+
Checksum,
34+
Patch,
35+
DependencyMissing,
36+
Configure,
37+
Compile,
38+
Link,
39+
Test,
40+
Install,
41+
Sanity,
42+
Resource,
43+
Timeout,
44+
Unknown,
45+
}
46+
47+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48+
#[serde(rename_all = "kebab-case")]
49+
pub enum FindingDisposition {
50+
Mechanical,
51+
Retryable,
52+
RequiresJudgment,
53+
TargetRepair,
54+
}
55+
56+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57+
#[serde(deny_unknown_fields)]
58+
pub struct BuildFinding {
59+
pub id: String,
60+
pub class: BuildFindingClass,
61+
pub disposition: FindingDisposition,
62+
pub stage: String,
63+
pub recipe: String,
64+
pub target: String,
65+
pub summary: String,
66+
pub evidence: String,
67+
pub command: CommandPlan,
68+
pub exit_code: Option<i32>,
69+
pub attempt: u32,
70+
}
71+
72+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
73+
#[serde(deny_unknown_fields)]
74+
pub struct ClaimLadder {
75+
pub resolves: bool,
76+
pub builds: bool,
77+
pub binary_verified: bool,
78+
}
79+
80+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81+
#[serde(deny_unknown_fields)]
82+
pub struct CampaignEvent {
83+
pub attempt: u32,
84+
pub status: CampaignStatus,
85+
pub recipe: Option<String>,
86+
pub detail: String,
87+
}
88+
89+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90+
#[serde(deny_unknown_fields)]
91+
pub struct CampaignState {
92+
pub schema_version: u32,
93+
pub package: String,
94+
pub version: String,
95+
pub bundle: String,
96+
pub target: String,
97+
pub status: CampaignStatus,
98+
pub attempts: u32,
99+
pub claims: ClaimLadder,
100+
#[serde(default, skip_serializing_if = "Option::is_none")]
101+
pub current_recipe: Option<String>,
102+
#[serde(default)]
103+
pub findings: Vec<BuildFinding>,
104+
#[serde(default)]
105+
pub history: Vec<CampaignEvent>,
106+
}
107+
108+
pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, CampaignError> {
109+
let manifest_path = request.bundle.join("package.plan.json");
110+
let manifest: Value = read_json(&manifest_path)?;
111+
let package = manifest
112+
.pointer("/package/name")
113+
.and_then(Value::as_str)
114+
.ok_or_else(|| CampaignError::InvalidBundle("manifest has no package.name".into()))?;
115+
let version = manifest
116+
.pointer("/package/version")
117+
.and_then(Value::as_str)
118+
.ok_or_else(|| CampaignError::InvalidBundle("manifest has no package.version".into()))?;
119+
let recipes = discover_files(&request.bundle.join("easyconfigs"), "eb")?;
120+
if recipes.is_empty() {
121+
return Err(CampaignError::InvalidBundle(
122+
"bundle has no EasyBuild recipes".into(),
123+
));
124+
}
125+
let locks = discover_files(&request.bundle.join("locks"), "json")?;
126+
if locks.is_empty() {
127+
return Err(CampaignError::InvalidBundle(
128+
"bundle has no Resolvo profile locks".into(),
129+
));
130+
}
131+
132+
let mut state = if request.state_path.is_file() {
133+
let state: CampaignState = read_json(&request.state_path)?;
134+
if state.schema_version != CAMPAIGN_SCHEMA_VERSION {
135+
return Err(CampaignError::UnsupportedSchema(state.schema_version));
136+
}
137+
if state.package != package || state.version != version {
138+
return Err(CampaignError::StateIdentity);
139+
}
140+
state
141+
} else {
142+
CampaignState {
143+
schema_version: CAMPAIGN_SCHEMA_VERSION,
144+
package: package.into(),
145+
version: version.into(),
146+
bundle: request.bundle.display().to_string(),
147+
target: request.target.name.clone(),
148+
status: CampaignStatus::Planned,
149+
attempts: 0,
150+
claims: ClaimLadder {
151+
resolves: true,
152+
builds: false,
153+
binary_verified: false,
154+
},
155+
current_recipe: None,
156+
findings: Vec::new(),
157+
history: Vec::new(),
158+
}
159+
};
160+
161+
state.attempts += 1;
162+
state.target = request.target.name.clone();
163+
state.status = CampaignStatus::Running;
164+
state.claims.builds = false;
165+
state.history.push(CampaignEvent {
166+
attempt: state.attempts,
167+
status: CampaignStatus::Running,
168+
recipe: None,
169+
detail: format!("build evaluation on {}", request.target.name),
170+
});
171+
write_state(&request.state_path, &state)?;
172+
173+
for recipe in recipes {
174+
let recipe_text = recipe.display().to_string();
175+
state.current_recipe = Some(recipe_text.clone());
176+
write_state(&request.state_path, &state)?;
177+
let command = request.target.build_command(&recipe_text);
178+
let output = command.execute().map_err(CampaignError::Target)?;
179+
if !output.status.success() {
180+
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
181+
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
182+
let class = classify_build_failure("build", &stdout, &stderr, output.status.code());
183+
let evidence = compact_evidence(&stdout, &stderr);
184+
state.findings.push(BuildFinding {
185+
id: format!(
186+
"attempt:{}:finding:{}",
187+
state.attempts,
188+
state.findings.len() + 1
189+
),
190+
class,
191+
disposition: disposition(class),
192+
stage: "build".into(),
193+
recipe: recipe_text.clone(),
194+
target: request.target.name.clone(),
195+
summary: finding_summary(class, output.status.code()),
196+
evidence,
197+
command,
198+
exit_code: output.status.code(),
199+
attempt: state.attempts,
200+
});
201+
state.status = CampaignStatus::Failed;
202+
state.current_recipe = None;
203+
state.history.push(CampaignEvent {
204+
attempt: state.attempts,
205+
status: CampaignStatus::Failed,
206+
recipe: Some(recipe_text),
207+
detail: format!("classified build failure as {class:?}"),
208+
});
209+
write_state(&request.state_path, &state)?;
210+
return Ok(state);
211+
}
212+
state.history.push(CampaignEvent {
213+
attempt: state.attempts,
214+
status: CampaignStatus::Running,
215+
recipe: Some(recipe_text),
216+
detail: "EasyBuild command succeeded".into(),
217+
});
218+
}
219+
220+
state.status = CampaignStatus::Completed;
221+
state.current_recipe = None;
222+
state.claims.builds = true;
223+
state.history.push(CampaignEvent {
224+
attempt: state.attempts,
225+
status: CampaignStatus::Completed,
226+
recipe: None,
227+
detail: "all EasyBuild commands succeeded".into(),
228+
});
229+
write_state(&request.state_path, &state)?;
230+
Ok(state)
231+
}
232+
233+
pub fn classify_build_failure(
234+
stage: &str,
235+
stdout: &str,
236+
stderr: &str,
237+
exit_code: Option<i32>,
238+
) -> BuildFindingClass {
239+
let text = format!("{stage}\n{stdout}\n{stderr}").to_ascii_lowercase();
240+
if text.contains("ssh:")
241+
|| text.contains("connection refused")
242+
|| text.contains("connection timed out")
243+
{
244+
BuildFindingClass::Transport
245+
} else if text.contains("slurm") && (text.contains("error") || text.contains("invalid")) {
246+
if text.contains("oom") || text.contains("out of memory") {
247+
BuildFindingClass::Resource
248+
} else {
249+
BuildFindingClass::Executor
250+
}
251+
} else if text.contains("oom-kill") || text.contains("out of memory") {
252+
BuildFindingClass::Resource
253+
} else if text.contains("checksum") && (text.contains("failed") || text.contains("mismatch")) {
254+
BuildFindingClass::Checksum
255+
} else if text.contains("patch") && (text.contains("failed") || text.contains("reject")) {
256+
BuildFindingClass::Patch
257+
} else if text.contains("no such file or directory")
258+
&& (text.contains("fatal error") || text.contains("header"))
259+
{
260+
BuildFindingClass::DependencyMissing
261+
} else if text.contains("cmake error")
262+
|| text.contains("configure: error")
263+
|| text.contains("meson.build:")
264+
{
265+
BuildFindingClass::Configure
266+
} else if text.contains("undefined reference") || text.contains("ld: cannot find") {
267+
BuildFindingClass::Link
268+
} else if text.contains("tests failed")
269+
|| text.contains("test failed")
270+
|| text.contains("ctest") && text.contains("failed")
271+
{
272+
BuildFindingClass::Test
273+
} else if text.contains("sanity check failed") {
274+
BuildFindingClass::Sanity
275+
} else if text.contains("install") && text.contains("failed") {
276+
BuildFindingClass::Install
277+
} else if text.contains("timed out") || exit_code == Some(124) {
278+
BuildFindingClass::Timeout
279+
} else if text.contains("error:") || text.contains("compilation terminated") {
280+
BuildFindingClass::Compile
281+
} else {
282+
BuildFindingClass::Unknown
283+
}
284+
}
285+
286+
fn disposition(class: BuildFindingClass) -> FindingDisposition {
287+
match class {
288+
BuildFindingClass::Transport | BuildFindingClass::Executor | BuildFindingClass::Runtime => {
289+
FindingDisposition::TargetRepair
290+
}
291+
BuildFindingClass::Resource | BuildFindingClass::Timeout => FindingDisposition::Retryable,
292+
BuildFindingClass::Checksum => FindingDisposition::Mechanical,
293+
_ => FindingDisposition::RequiresJudgment,
294+
}
295+
}
296+
297+
fn finding_summary(class: BuildFindingClass, exit_code: Option<i32>) -> String {
298+
format!("{class:?} failure from EasyBuild command (exit {exit_code:?})")
299+
}
300+
301+
fn compact_evidence(stdout: &str, stderr: &str) -> String {
302+
let combined = format!("stdout:\n{stdout}\nstderr:\n{stderr}");
303+
let lines = combined.lines().collect::<Vec<_>>();
304+
let start = lines.len().saturating_sub(200);
305+
let mut compact = lines[start..].join("\n");
306+
if compact.len() > 64 * 1024 {
307+
compact = compact[compact.len() - 64 * 1024..].to_string();
308+
}
309+
compact
310+
}
311+
312+
fn discover_files(root: &Path, extension: &str) -> Result<Vec<PathBuf>, CampaignError> {
313+
if !root.is_dir() {
314+
return Ok(Vec::new());
315+
}
316+
let mut files = Vec::new();
317+
let mut directories = vec![root.to_path_buf()];
318+
while let Some(directory) = directories.pop() {
319+
for entry in std::fs::read_dir(&directory)
320+
.map_err(|error| CampaignError::Io(directory.clone(), error))?
321+
{
322+
let entry = entry.map_err(|error| CampaignError::Io(directory.clone(), error))?;
323+
let path = entry.path();
324+
if path.is_dir() {
325+
directories.push(path);
326+
} else if path.extension().and_then(|value| value.to_str()) == Some(extension) {
327+
files.push(path);
328+
}
329+
}
330+
}
331+
files.sort();
332+
Ok(files)
333+
}
334+
335+
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, CampaignError> {
336+
let text = std::fs::read_to_string(path)
337+
.map_err(|error| CampaignError::Io(path.to_path_buf(), error))?;
338+
serde_json::from_str(&text).map_err(|error| CampaignError::Json(path.to_path_buf(), error))
339+
}
340+
341+
fn write_state(path: &Path, state: &CampaignState) -> Result<(), CampaignError> {
342+
if let Some(parent) = path.parent() {
343+
std::fs::create_dir_all(parent)
344+
.map_err(|error| CampaignError::Io(parent.to_path_buf(), error))?;
345+
}
346+
let temporary = path.with_extension("tmp");
347+
let mut text = serde_json::to_string_pretty(state)
348+
.map_err(|error| CampaignError::Json(path.to_path_buf(), error))?;
349+
text.push('\n');
350+
std::fs::write(&temporary, text)
351+
.map_err(|error| CampaignError::Io(temporary.clone(), error))?;
352+
std::fs::rename(&temporary, path)
353+
.map_err(|error| CampaignError::Io(path.to_path_buf(), error))?;
354+
Ok(())
355+
}
356+
357+
#[derive(Debug, Error)]
358+
pub enum CampaignError {
359+
#[error("invalid package bundle: {0}")]
360+
InvalidBundle(String),
361+
#[error("unsupported campaign schema version {0}")]
362+
UnsupportedSchema(u32),
363+
#[error("campaign state package identity does not match the bundle")]
364+
StateIdentity,
365+
#[error("target command: {0}")]
366+
Target(#[from] TargetError),
367+
#[error("read or write {0}: {1}")]
368+
Io(PathBuf, std::io::Error),
369+
#[error("JSON {0}: {1}")]
370+
Json(PathBuf, serde_json::Error),
371+
}

‎src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! EasyBuild stack lock: parse `.eb` files, resolvo SAT co-select, planned SBOM.
22
33
pub mod domain;
4+
pub mod campaign;
45
pub mod eb_emit;
56
pub mod eb_parse;
67
pub mod eb_style;

0 commit comments

Comments
 (0)