@@ -4,6 +4,7 @@ use crate::package::ProductProfile;
44use crate :: target:: { BuildTarget , CommandPlan , TargetError } ;
55use serde:: { Deserialize , Serialize } ;
66use serde_json:: Value ;
7+ use std:: fs:: OpenOptions ;
78use std:: path:: { Path , PathBuf } ;
89use 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) ]
5979pub 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
109135pub 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+
354477pub 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 ) ]
549705pub 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