1use cel::{Context, Program, Value};
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33
34use crate::error::{ValidationError, ValidationResult};
35use crate::identity::MAX_EXPR_LEN;
36
37pub const UNKNOWN_AGE: i64 = i64::MAX;
39
40#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
43#[serde(rename_all = "camelCase")]
44pub struct PreflightSpec {
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 #[schemars(length(max = 50))]
49 pub checks: Vec<PreflightCheck>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub timeout: Option<String>,
55}
56
57#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
59#[serde(rename_all = "camelCase")]
60pub struct PreflightCheck {
61 #[schemars(length(min = 1, max = 63))]
64 pub name: String,
65 pub expr: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub message: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct PreflightInputs {
77 pub repository_phase: String,
79 pub repository_ready: bool,
81 pub backend_reachable: bool,
85 pub snapshot_count_known: bool,
87 pub snapshot_count: i64,
89 pub index_blob_count_known: bool,
91 pub index_blob_count: i64,
93 pub size_bytes_known: bool,
95 pub size_bytes: i64,
97 pub last_healthy_known: bool,
99 pub last_healthy_age_seconds: i64,
101 pub last_reverify_known: bool,
103 pub last_reverify_age_seconds: i64,
105 pub maintenance_has_run: bool,
108 pub maintenance_last_success_age_seconds: i64,
111}
112
113impl Default for PreflightInputs {
114 fn default() -> Self {
115 Self {
117 repository_phase: String::new(),
118 repository_ready: false,
119 backend_reachable: false,
120 snapshot_count_known: false,
121 snapshot_count: UNKNOWN_AGE,
122 index_blob_count_known: false,
123 index_blob_count: UNKNOWN_AGE,
124 size_bytes_known: false,
125 size_bytes: UNKNOWN_AGE,
126 last_healthy_known: false,
127 last_healthy_age_seconds: UNKNOWN_AGE,
128 last_reverify_known: false,
129 last_reverify_age_seconds: UNKNOWN_AGE,
130 maintenance_has_run: false,
131 maintenance_last_success_age_seconds: UNKNOWN_AGE,
132 }
133 }
134}
135
136fn compile(expr: &str) -> ValidationResult<Program> {
140 if expr.len() > MAX_EXPR_LEN {
141 return Err(ValidationError::PreflightExprCompile {
142 expr: expr.to_string(),
143 reason: format!(
144 "expression is {} bytes; the maximum is {MAX_EXPR_LEN}",
145 expr.len()
146 ),
147 });
148 }
149 Program::compile(expr).map_err(|e| ValidationError::PreflightExprCompile {
150 expr: expr.to_string(),
151 reason: e.to_string(),
152 })
153}
154
155fn context<'a>(inputs: &PreflightInputs) -> Context<'a> {
159 let mut ctx = Context::default();
160 let repository = serde_json::json!({
161 "phase": inputs.repository_phase,
162 "ready": inputs.repository_ready,
163 "backendReachable": inputs.backend_reachable,
164 "snapshotCountKnown": inputs.snapshot_count_known,
165 "snapshotCount": inputs.snapshot_count,
166 "indexBlobCountKnown": inputs.index_blob_count_known,
167 "indexBlobCount": inputs.index_blob_count,
168 "sizeBytesKnown": inputs.size_bytes_known,
169 "sizeBytes": inputs.size_bytes,
170 "lastHealthyKnown": inputs.last_healthy_known,
171 "lastHealthyAgeSeconds": inputs.last_healthy_age_seconds,
172 "lastReverifyKnown": inputs.last_reverify_known,
173 "lastReverifyAgeSeconds": inputs.last_reverify_age_seconds,
174 });
175 let maintenance = serde_json::json!({
176 "hasRun": inputs.maintenance_has_run,
177 "lastSuccessAgeSeconds": inputs.maintenance_last_success_age_seconds,
178 });
179 let _ = ctx.add_variable("repository", &repository);
180 let _ = ctx.add_variable("maintenance", &maintenance);
181 ctx
182}
183
184pub fn eval_preflight_expr(expr: &str, inputs: &PreflightInputs) -> ValidationResult<bool> {
188 let program = compile(expr)?;
189 let ctx = context(inputs);
190 match program.execute(&ctx) {
191 Ok(Value::Bool(b)) => Ok(b),
192 Ok(other) => Err(ValidationError::PreflightExprType {
193 expr: expr.to_string(),
194 got: other.type_of().to_string(),
195 }),
196 Err(e) => Err(ValidationError::PreflightExprEval {
197 expr: expr.to_string(),
198 reason: e.to_string(),
199 }),
200 }
201}
202
203pub fn validate_preflight_expr(expr: &str) -> ValidationResult {
210 let program = compile(expr)?;
211 let inputs = PreflightInputs {
214 repository_phase: "Ready".to_string(),
215 repository_ready: true,
216 backend_reachable: true,
217 snapshot_count_known: true,
218 snapshot_count: 1,
219 index_blob_count_known: true,
220 index_blob_count: 1,
221 size_bytes_known: true,
222 size_bytes: 1,
223 last_healthy_known: true,
224 last_healthy_age_seconds: 1,
225 last_reverify_known: true,
226 last_reverify_age_seconds: 1,
227 maintenance_has_run: true,
228 maintenance_last_success_age_seconds: 1,
229 };
230 let ctx = context(&inputs);
231 match program.execute(&ctx) {
232 Ok(Value::Bool(_)) => Ok(()),
233 Ok(other) => Err(ValidationError::PreflightExprType {
234 expr: expr.to_string(),
235 got: other.type_of().to_string(),
236 }),
237 Err(cel::ExecutionError::UndeclaredReference(name)) => {
240 Err(ValidationError::PreflightExprEval {
241 expr: expr.to_string(),
242 reason: format!("undeclared reference to '{name}'"),
243 })
244 }
245 Err(_) => Ok(()),
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn ready_inputs() -> PreflightInputs {
254 PreflightInputs {
255 repository_phase: "Ready".to_string(),
256 repository_ready: true,
257 backend_reachable: true,
258 snapshot_count_known: true,
259 snapshot_count: 12,
260 index_blob_count_known: true,
261 index_blob_count: 5,
262 size_bytes_known: true,
263 size_bytes: 4096,
264 last_healthy_known: true,
265 last_healthy_age_seconds: 60,
266 last_reverify_known: true,
267 last_reverify_age_seconds: 120,
268 maintenance_has_run: true,
269 maintenance_last_success_age_seconds: 3600,
270 }
271 }
272
273 #[test]
274 fn evaluates_true_and_false() {
275 let i = ready_inputs();
276 assert!(eval_preflight_expr("repository.ready", &i).unwrap());
277 assert!(eval_preflight_expr("repository.snapshotCount >= 0", &i).unwrap());
278 assert!(!eval_preflight_expr("repository.snapshotCount > 1000000", &i).unwrap());
279 }
280
281 #[test]
282 fn maintenance_freshness_with_guard() {
283 let i = ready_inputs(); let expr = "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800";
285 assert!(eval_preflight_expr(expr, &i).unwrap());
286 }
287
288 #[test]
289 fn unknown_age_fails_closed_for_freshness() {
290 let mut i = ready_inputs();
293 i.maintenance_has_run = false;
294 i.maintenance_last_success_age_seconds = UNKNOWN_AGE;
295 assert!(
296 !eval_preflight_expr("maintenance.lastSuccessAgeSeconds < 604800", &i).unwrap(),
297 "i64::MAX sentinel must make a naive freshness check fail closed"
298 );
299 assert!(
301 !eval_preflight_expr(
302 "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800",
303 &i
304 )
305 .unwrap()
306 );
307 }
308
309 #[test]
310 fn unobserved_count_must_be_guarded_with_known() {
311 let mut i = ready_inputs();
314 i.snapshot_count_known = false;
315 i.snapshot_count = UNKNOWN_AGE;
316 assert!(
317 eval_preflight_expr("repository.snapshotCount > 0", &i).unwrap(),
318 "the count sentinel is i64::MAX, so an UNGUARDED > 0 check fails open"
319 );
320 assert!(
322 !eval_preflight_expr(
323 "repository.snapshotCountKnown && repository.snapshotCount > 0",
324 &i
325 )
326 .unwrap(),
327 "guarding with snapshotCountKnown blocks when the count is unobserved"
328 );
329 }
330
331 #[test]
332 fn known_bools_are_available() {
333 let mut i = ready_inputs();
334 i.last_healthy_known = false;
335 i.last_healthy_age_seconds = UNKNOWN_AGE;
336 assert!(!eval_preflight_expr("repository.lastHealthyKnown", &i).unwrap());
337 assert!(eval_preflight_expr("repository.backendReachable", &i).unwrap());
338 }
339
340 #[test]
341 fn non_bool_result_is_an_error() {
342 let err = eval_preflight_expr("repository.snapshotCount", &ready_inputs()).unwrap_err();
343 assert!(matches!(err, ValidationError::PreflightExprType { .. }));
344 }
345
346 #[test]
347 fn typo_is_an_error() {
348 let err = eval_preflight_expr("repositoryy.ready", &ready_inputs()).unwrap_err();
350 assert!(matches!(err, ValidationError::PreflightExprEval { .. }));
351 }
352
353 #[test]
356 fn validate_accepts_valid_bool_exprs() {
357 assert!(validate_preflight_expr("repository.ready").is_ok());
358 assert!(
359 validate_preflight_expr(
360 "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800"
361 )
362 .is_ok()
363 );
364 assert!(validate_preflight_expr("repository.sizeBytes >= 0").is_ok());
365 assert!(
366 validate_preflight_expr("repository.backendReachable && repository.snapshotCount >= 0")
367 .is_ok()
368 );
369 }
370
371 #[test]
372 fn validate_rejects_syntax_error() {
373 let err = validate_preflight_expr("repository.ready &&").unwrap_err();
374 assert!(matches!(err, ValidationError::PreflightExprCompile { .. }));
375 }
376
377 #[test]
378 fn validate_rejects_out_of_scope_variable() {
379 let err = validate_preflight_expr("bogus > 0").unwrap_err();
380 assert!(matches!(err, ValidationError::PreflightExprEval { .. }));
381 }
382
383 #[test]
384 fn validate_rejects_non_bool_result() {
385 let err = validate_preflight_expr("repository.phase").unwrap_err();
387 assert!(matches!(err, ValidationError::PreflightExprType { .. }));
388 }
389
390 #[test]
391 fn validate_rejects_over_length_expr() {
392 let long = format!("repository.snapshotCount == {} ", "1".repeat(MAX_EXPR_LEN));
393 let err = validate_preflight_expr(&long).unwrap_err();
394 assert!(matches!(err, ValidationError::PreflightExprCompile { .. }));
395 }
396}