Skip to content

Commit 60db716

Browse files
committed
feat(campaign): coordinate owned finding repairs
1 parent 41ce691 commit 60db716

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

‎src/campaign.rs‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::package::ProductProfile;
44
use crate::target::{BuildTarget, CommandPlan, TargetError};
55
use serde::{Deserialize, Serialize};
66
use serde_json::Value;
7+
use std::fs::OpenOptions;
78
use std::path::{Path, PathBuf};
89
use thiserror::Error;
910

@@ -54,6 +55,25 @@ pub enum FindingDisposition {
5455
TargetRepair,
5556
}
5657

58+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
59+
#[serde(rename_all = "kebab-case")]
60+
pub enum FindingStatus {
61+
#[default]
62+
Open,
63+
InProgress,
64+
Resolved,
65+
Superseded,
66+
}
67+
68+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69+
#[serde(deny_unknown_fields)]
70+
pub struct FindingResolution {
71+
pub action: String,
72+
pub evidence: String,
73+
#[serde(default)]
74+
pub changes: Vec<String>,
75+
}
76+
5777
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5878
#[serde(deny_unknown_fields)]
5979
pub struct BuildFinding {
@@ -68,6 +88,12 @@ pub struct BuildFinding {
6888
pub command: CommandPlan,
6989
pub exit_code: Option<i32>,
7090
pub attempt: u32,
91+
#[serde(default)]
92+
pub status: FindingStatus,
93+
#[serde(default, skip_serializing_if = "Option::is_none")]
94+
pub owner: Option<String>,
95+
#[serde(default, skip_serializing_if = "Option::is_none")]
96+
pub resolution: Option<FindingResolution>,
7197
}
7298

7399
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -107,6 +133,7 @@ pub struct CampaignState {
107133
}
108134

109135
pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, CampaignError> {
136+
let _lock = CampaignLock::acquire(&request.state_path)?;
110137
let manifest_path = request.bundle.join("package.plan.json");
111138
let manifest: Value = read_json(&manifest_path)?;
112139
let package = manifest
@@ -194,6 +221,9 @@ pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, Campaign
194221
},
195222
exit_code: None,
196223
attempt: state.attempts,
224+
status: FindingStatus::Open,
225+
owner: None,
226+
resolution: None,
197227
});
198228
state.status = CampaignStatus::Failed;
199229
state.current_recipe = None;
@@ -241,6 +271,9 @@ pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, Campaign
241271
command,
242272
exit_code: output.status.code(),
243273
attempt: state.attempts,
274+
status: FindingStatus::Open,
275+
owner: None,
276+
resolution: None,
244277
});
245278
state.status = CampaignStatus::Failed;
246279
state.current_recipe = None;
@@ -259,6 +292,7 @@ pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, Campaign
259292
recipe: Some(recipe_text),
260293
detail: "EasyBuild command succeeded".into(),
261294
});
295+
supersede_findings(&mut state, "build", &recipe_text);
262296
}
263297

264298
state.current_recipe = None;
@@ -314,6 +348,9 @@ pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, Campaign
314348
command,
315349
exit_code: output.status.code(),
316350
attempt: state.attempts,
351+
status: FindingStatus::Open,
352+
owner: None,
353+
resolution: None,
317354
});
318355
state.status = CampaignStatus::Failed;
319356
state.history.push(CampaignEvent {
@@ -331,6 +368,7 @@ pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, Campaign
331368
recipe: Some(format!("profile:{}", profile.name)),
332369
detail: format!("binary verification succeeded for module {module}"),
333370
});
371+
supersede_findings(&mut state, "verify", &format!("profile:{}", profile.name));
334372
write_state(&request.state_path, &state)?;
335373
}
336374
}
@@ -351,6 +389,91 @@ pub fn run_campaign(request: &CampaignRequest) -> Result<CampaignState, Campaign
351389
Ok(state)
352390
}
353391

392+
pub fn claim_finding(
393+
state_path: &Path,
394+
finding_id: &str,
395+
owner: &str,
396+
) -> Result<CampaignState, CampaignError> {
397+
let _lock = CampaignLock::acquire(state_path)?;
398+
let mut state: CampaignState = read_json(state_path)?;
399+
let finding = state
400+
.findings
401+
.iter_mut()
402+
.find(|finding| finding.id == finding_id)
403+
.ok_or_else(|| CampaignError::FindingNotFound(finding_id.into()))?;
404+
match finding.status {
405+
FindingStatus::Open => {
406+
finding.status = FindingStatus::InProgress;
407+
finding.owner = Some(owner.into());
408+
}
409+
FindingStatus::InProgress if finding.owner.as_deref() == Some(owner) => {}
410+
FindingStatus::InProgress => {
411+
return Err(CampaignError::FindingOwned {
412+
id: finding_id.into(),
413+
owner: finding.owner.clone().unwrap_or_else(|| "unknown".into()),
414+
});
415+
}
416+
status => {
417+
return Err(CampaignError::FindingState {
418+
id: finding_id.into(),
419+
status,
420+
});
421+
}
422+
}
423+
write_state(state_path, &state)?;
424+
Ok(state)
425+
}
426+
427+
pub fn resolve_finding(
428+
state_path: &Path,
429+
finding_id: &str,
430+
owner: &str,
431+
resolution: FindingResolution,
432+
) -> Result<CampaignState, CampaignError> {
433+
let _lock = CampaignLock::acquire(state_path)?;
434+
let mut state: CampaignState = read_json(state_path)?;
435+
let finding = state
436+
.findings
437+
.iter_mut()
438+
.find(|finding| finding.id == finding_id)
439+
.ok_or_else(|| CampaignError::FindingNotFound(finding_id.into()))?;
440+
if finding.status != FindingStatus::InProgress {
441+
return Err(CampaignError::FindingState {
442+
id: finding_id.into(),
443+
status: finding.status,
444+
});
445+
}
446+
if finding.owner.as_deref() != Some(owner) {
447+
return Err(CampaignError::FindingOwned {
448+
id: finding_id.into(),
449+
owner: finding.owner.clone().unwrap_or_else(|| "unknown".into()),
450+
});
451+
}
452+
finding.status = FindingStatus::Resolved;
453+
finding.resolution = Some(resolution);
454+
write_state(state_path, &state)?;
455+
Ok(state)
456+
}
457+
458+
fn supersede_findings(state: &mut CampaignState, stage: &str, recipe: &str) {
459+
for finding in &mut state.findings {
460+
if finding.stage == stage
461+
&& finding.recipe == recipe
462+
&& matches!(
463+
finding.status,
464+
FindingStatus::Open | FindingStatus::InProgress
465+
)
466+
{
467+
finding.status = FindingStatus::Superseded;
468+
finding.resolution.get_or_insert_with(|| FindingResolution {
469+
action: "successful campaign retry superseded this finding".into(),
470+
evidence: format!("attempt {} succeeded at stage {stage}", state.attempts),
471+
changes: Vec::new(),
472+
});
473+
}
474+
}
475+
}
476+
354477
pub fn classify_build_failure(
355478
stage: &str,
356479
stdout: &str,
@@ -545,6 +668,39 @@ fn write_state(path: &Path, state: &CampaignState) -> Result<(), CampaignError>
545668
Ok(())
546669
}
547670

671+
struct CampaignLock {
672+
path: PathBuf,
673+
_file: std::fs::File,
674+
}
675+
676+
impl CampaignLock {
677+
fn acquire(state_path: &Path) -> Result<Self, CampaignError> {
678+
if let Some(parent) = state_path.parent() {
679+
std::fs::create_dir_all(parent)
680+
.map_err(|error| CampaignError::Io(parent.to_path_buf(), error))?;
681+
}
682+
let path = PathBuf::from(format!("{}.lock", state_path.display()));
683+
let file = OpenOptions::new()
684+
.write(true)
685+
.create_new(true)
686+
.open(&path)
687+
.map_err(|error| {
688+
if error.kind() == std::io::ErrorKind::AlreadyExists {
689+
CampaignError::Busy(path.clone())
690+
} else {
691+
CampaignError::Io(path.clone(), error)
692+
}
693+
})?;
694+
Ok(Self { path, _file: file })
695+
}
696+
}
697+
698+
impl Drop for CampaignLock {
699+
fn drop(&mut self) {
700+
let _ = std::fs::remove_file(&self.path);
701+
}
702+
}
703+
548704
#[derive(Debug, Error)]
549705
pub enum CampaignError {
550706
#[error("invalid package bundle: {0}")]
@@ -553,6 +709,14 @@ pub enum CampaignError {
553709
UnsupportedSchema(u32),
554710
#[error("campaign state package identity does not match the bundle")]
555711
StateIdentity,
712+
#[error("campaign state is busy: {0}")]
713+
Busy(PathBuf),
714+
#[error("campaign finding {0} does not exist")]
715+
FindingNotFound(String),
716+
#[error("campaign finding {id} is owned by {owner}")]
717+
FindingOwned { id: String, owner: String },
718+
#[error("campaign finding {id} cannot be changed from status {status:?}")]
719+
FindingState { id: String, status: FindingStatus },
556720
#[error("target command: {0}")]
557721
Target(#[from] TargetError),
558722
#[error("read or write {0}: {1}")]

0 commit comments

Comments
 (0)