edda_conductor/check/
engine.rs1use crate::check::CheckOutput;
2use crate::plan::schema::CheckSpec;
3use crate::state::machine::{CheckResult, CheckStatus, ErrorInfo, ErrorType};
4use std::path::PathBuf;
5
6#[derive(Debug)]
8pub struct CheckRunResult {
9 pub all_passed: bool,
10 pub results: Vec<CheckResult>,
11 pub error: Option<ErrorInfo>,
12}
13
14pub struct CheckEngine {
16 cwd: PathBuf,
17}
18
19impl CheckEngine {
20 pub fn new(cwd: PathBuf) -> Self {
21 Self { cwd }
22 }
23
24 pub async fn run_all(
26 &self,
27 checks: &[CheckSpec],
28 phase_started_at: Option<&str>,
29 ) -> CheckRunResult {
30 let mut results = Vec::new();
31
32 for (i, spec) in checks.iter().enumerate() {
33 let output = self.run_one(spec, phase_started_at).await;
34 let status = if output.passed {
35 CheckStatus::Passed
36 } else {
37 CheckStatus::Failed
38 };
39
40 results.push(CheckResult {
41 check_type: spec.type_name().to_string(),
42 status,
43 detail: output.detail.clone(),
44 duration_ms: output.duration.as_millis() as u64,
45 });
46
47 if !output.passed {
48 for check in &checks[(i + 1)..] {
50 results.push(CheckResult {
51 check_type: check.type_name().to_string(),
52 status: CheckStatus::Waiting,
53 detail: None,
54 duration_ms: 0,
55 });
56 }
57 return CheckRunResult {
58 all_passed: false,
59 results,
60 error: Some(ErrorInfo {
61 error_type: ErrorType::CheckFailed,
62 message: output
63 .detail
64 .unwrap_or_else(|| format!("check {} failed", spec.type_name())),
65 retryable: spec.is_retryable(),
66 check_index: Some(i),
67 timestamp: now_rfc3339(),
68 }),
69 };
70 }
71 }
72
73 CheckRunResult {
74 all_passed: true,
75 results,
76 error: None,
77 }
78 }
79
80 async fn run_one(&self, spec: &CheckSpec, phase_started_at: Option<&str>) -> CheckOutput {
82 match spec {
83 CheckSpec::FileExists { path } => {
84 crate::check::file_exists::check_file_exists(path, &self.cwd)
85 }
86 CheckSpec::CmdSucceeds { cmd, timeout_sec } => {
87 crate::check::cmd_succeeds::check_cmd_succeeds(cmd, *timeout_sec, &self.cwd).await
88 }
89 CheckSpec::FileContains { path, pattern } => {
90 crate::check::file_contains::check_file_contains(path, pattern, &self.cwd)
91 }
92 CheckSpec::GitClean { allow_untracked } => {
93 crate::check::git_clean::check_git_clean(*allow_untracked, &self.cwd).await
94 }
95 CheckSpec::EddaEvent { event_type, after } => {
96 let after_val = after.as_deref().map(|a| {
97 if a == "$phase_start" {
98 phase_started_at.unwrap_or("")
99 } else {
100 a
101 }
102 });
103 crate::check::edda_event::check_edda_event(event_type, after_val, &self.cwd).await
104 }
105 CheckSpec::WaitUntil {
106 check,
107 interval_sec,
108 timeout_sec,
109 backoff,
110 } => {
111 crate::check::wait_until::check_wait_until(
112 check,
113 *interval_sec,
114 *timeout_sec,
115 *backoff,
116 &self.cwd,
117 phase_started_at,
118 )
119 .await
120 }
121 }
122 }
123}
124
125fn now_rfc3339() -> String {
126 time::OffsetDateTime::now_utc()
127 .format(&time::format_description::well_known::Rfc3339)
128 .unwrap_or_default()
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::plan::schema::CheckSpec;
135
136 #[tokio::test]
137 async fn empty_checks_pass() {
138 let dir = tempfile::tempdir().unwrap();
139 let engine = CheckEngine::new(dir.path().to_path_buf());
140 let result = engine.run_all(&[], None).await;
141 assert!(result.all_passed);
142 assert!(result.results.is_empty());
143 }
144
145 #[tokio::test]
146 async fn file_exists_check_passes() {
147 let dir = tempfile::tempdir().unwrap();
148 std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
149
150 let engine = CheckEngine::new(dir.path().to_path_buf());
151 let checks = vec![CheckSpec::FileExists {
152 path: "test.txt".into(),
153 }];
154 let result = engine.run_all(&checks, None).await;
155 assert!(result.all_passed);
156 assert_eq!(result.results.len(), 1);
157 assert_eq!(result.results[0].status, CheckStatus::Passed);
158 }
159
160 #[tokio::test]
161 async fn file_exists_check_fails() {
162 let dir = tempfile::tempdir().unwrap();
163 let engine = CheckEngine::new(dir.path().to_path_buf());
164 let checks = vec![CheckSpec::FileExists {
165 path: "nonexistent.txt".into(),
166 }];
167 let result = engine.run_all(&checks, None).await;
168 assert!(!result.all_passed);
169 assert!(result.error.is_some());
170 }
171
172 #[tokio::test]
173 async fn short_circuit_on_failure() {
174 let dir = tempfile::tempdir().unwrap();
175 let engine = CheckEngine::new(dir.path().to_path_buf());
176
177 let checks = vec![
178 CheckSpec::FileExists {
179 path: "nonexistent.txt".into(),
180 },
181 CheckSpec::FileExists {
182 path: "also-missing.txt".into(),
183 },
184 ];
185 let result = engine.run_all(&checks, None).await;
186 assert!(!result.all_passed);
187 assert_eq!(result.results.len(), 2);
188 assert_eq!(result.results[0].status, CheckStatus::Failed);
189 assert_eq!(result.results[1].status, CheckStatus::Waiting); }
191}