Skip to main content

kopiur_api/
preflight.rs

1//! Backup preflight: user-declared CEL preconditions a `Snapshot` must satisfy
2//! before its mover Job is launched (the "stronger preflight" of
3//! `docs/repository-health.md`).
4//!
5//! This reuses the `cel`/`*Expr` foundation from [`crate::success_expr`] and
6//! [`crate::identity`]: compile → execute → require a typed result, length-capped
7//! as the cost-budget surrogate, out-of-scope variables rejected at admission. A
8//! preflight check returns a **bool** over a live repository + maintenance
9//! environment, evaluated by the controller at reconcile (unlike `successExpr`,
10//! which the mover evaluates against a finished verify result).
11//!
12//! ## CEL environment
13//!
14//! Two maps, always present:
15//!
16//! - `repository` — `{ phase, ready, backendReachable, snapshotCount, indexBlobCount,
17//!   sizeBytes, lastHealthyKnown, lastHealthyAgeSeconds, lastReverifyKnown,
18//!   lastReverifyAgeSeconds }`.
19//! - `maintenance` — `{ hasRun, lastSuccessAgeSeconds }`.
20//!
21//! ## Fail-closed sentinels
22//!
23//! Every `*AgeSeconds` / count is an integer; "never / unknown" is encoded as
24//! [`i64::MAX`] (NOT `-1`). A naive freshness guard
25//! `maintenance.lastSuccessAgeSeconds < 604800` then reads `i64::MAX < 604800 ==
26//! false` and correctly **blocks** the backup when the value is unknown, instead of
27//! silently passing (which a `-1` sentinel would do). The companion `*Known` /
28//! `hasRun` booleans let an expression branch explicitly when it prefers to.
29
30use cel::{Context, Program, Value};
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33
34use crate::error::{ValidationError, ValidationResult};
35use crate::identity::MAX_EXPR_LEN;
36
37/// Sentinel for an unknown/never integer input, so a freshness guard fails closed.
38pub const UNKNOWN_AGE: i64 = i64::MAX;
39
40/// `SnapshotPolicy.spec.preflight` — named preconditions a backup run must satisfy
41/// before the mover Job is created. Opt-in; absent ⇒ no preflight.
42#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
43#[serde(rename_all = "camelCase")]
44pub struct PreflightSpec {
45    /// Checks that must **all** pass (AND) before the backup launches. An empty
46    /// list is an inert no-op (symmetric with `verification` absent).
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    #[schemars(length(max = 50))]
49    pub checks: Vec<PreflightCheck>,
50    /// How long to hold a `Snapshot` in `Pending` while a check is unsatisfied
51    /// before failing it (Go-style duration like `10m` or `1h`; default `10m`). A
52    /// zero duration (`0`/`0s`) holds indefinitely (never fail on preflight).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub timeout: Option<String>,
55}
56
57/// One named precondition: a CEL bool predicate over the preflight environment.
58#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
59#[serde(rename_all = "camelCase")]
60pub struct PreflightCheck {
61    /// Stable identifier surfaced in the `Snapshot`'s status when this check blocks
62    /// (e.g. `maintenance-fresh`). Unique within the policy.
63    #[schemars(length(min = 1, max = 63))]
64    pub name: String,
65    /// CEL bool predicate; the backup proceeds only when this evaluates `true`.
66    pub expr: String,
67    /// Optional human message surfaced alongside the check name when it blocks.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub message: Option<String>,
70}
71
72/// The full environment a preflight check evaluates against. A plain value struct
73/// (no `kube`/`tokio`) — the controller gathers live repository + maintenance state
74/// and fills it; the evaluator is pure.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct PreflightInputs {
77    /// `repository.phase` — the `Repository`/`ClusterRepository` status phase label.
78    pub repository_phase: String,
79    /// `repository.ready` — `phase == Ready`.
80    pub repository_ready: bool,
81    /// `repository.backendReachable` — the `BackendReachable` condition is `True`,
82    /// or `true` when the condition is absent (the health probe is disabled, so
83    /// there is no evidence the backend is down).
84    pub backend_reachable: bool,
85    /// `repository.snapshotCountKnown` — the snapshot count has been observed.
86    pub snapshot_count_known: bool,
87    /// `repository.snapshotCount` — `status.storageStats.snapshotCount`, [`UNKNOWN_AGE`] if unobserved.
88    pub snapshot_count: i64,
89    /// `repository.indexBlobCountKnown` — the index-blob count has been observed.
90    pub index_blob_count_known: bool,
91    /// `repository.indexBlobCount` — `status.storageStats.indexBlobCount`, [`UNKNOWN_AGE`] if unobserved.
92    pub index_blob_count: i64,
93    /// `repository.sizeBytesKnown` — the repository size has been observed.
94    pub size_bytes_known: bool,
95    /// `repository.sizeBytes` — `status.storageStats.totalSizeBytes`, [`UNKNOWN_AGE`] if unobserved.
96    pub size_bytes: i64,
97    /// `repository.lastHealthyKnown` — a successful health probe has been recorded.
98    pub last_healthy_known: bool,
99    /// `repository.lastHealthyAgeSeconds` — secs since `status.health.lastHealthyAt`, [`UNKNOWN_AGE`] if never.
100    pub last_healthy_age_seconds: i64,
101    /// `repository.lastReverifyKnown` — a reverify has been recorded.
102    pub last_reverify_known: bool,
103    /// `repository.lastReverifyAgeSeconds` — secs since `status.lastReverifyAt`, [`UNKNOWN_AGE`] if never.
104    pub last_reverify_age_seconds: i64,
105    /// `maintenance.hasRun` — the repo's `Maintenance` has a recorded successful run
106    /// (scheduled or manual run-now).
107    pub maintenance_has_run: bool,
108    /// `maintenance.lastSuccessAgeSeconds` — secs since the most recent successful
109    /// maintenance of any mode, [`UNKNOWN_AGE`] if never.
110    pub maintenance_last_success_age_seconds: i64,
111}
112
113impl Default for PreflightInputs {
114    fn default() -> Self {
115        // The fail-closed default: nothing observed yet, so freshness guards block.
116        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
136/// Compile a preflight expression, enforcing the [`MAX_EXPR_LEN`] budget first
137/// (shared with identity / `successExpr`). Maps a parse failure to
138/// [`ValidationError::PreflightExprCompile`].
139fn 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
155/// Build the CEL context: the `repository` and `maintenance` maps. Each is a JSON
156/// object so int/bool/string values coexist under one variable (mirrors
157/// `success_expr::context`'s `restored` object).
158fn 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
184/// Evaluate a preflight expression against `inputs`, requiring a bool result. Maps
185/// an evaluation failure to [`ValidationError::PreflightExprEval`] and a non-bool
186/// result to [`ValidationError::PreflightExprType`].
187pub 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
203/// Validate a preflight expression at admission: it must compile, and — because
204/// CEL reports an out-of-scope variable only at evaluation time — it must
205/// trial-evaluate against a representative environment without referencing an
206/// undeclared variable, returning a **bool**. Missing *map keys* on a
207/// data-dependent index are tolerated, mirroring
208/// [`crate::success_expr::validate_success_expr`].
209pub fn validate_preflight_expr(expr: &str) -> ValidationResult {
210    let program = compile(expr)?;
211    // A representative non-trivial environment (observed values, not the sentinel)
212    // so guards behave during the trial.
213    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        // An undeclared-variable reference (typo / out-of-scope) is a hard reject;
238        // other runtime errors (NoSuchKey on a data-dependent map index) tolerated.
239        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(); // last success 1h ago
284        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        // The headline fail-closed property: a naive `< 7d` freshness check must
291        // BLOCK (eval false) when the value was never observed, not silently pass.
292        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        // The explicit guard reads the same way.
300        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        // A count sentinel of i64::MAX fails OPEN for a `> 0` check, so a naive
312        // "repo is populated" guard wrongly passes before the first catalog scan.
313        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        // The `*Known` companion lets the user guard it explicitly (fail closed).
321        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        // An undeclared top-level variable (typo) fails at evaluation.
349        let err = eval_preflight_expr("repositoryy.ready", &ready_inputs()).unwrap_err();
350        assert!(matches!(err, ValidationError::PreflightExprEval { .. }));
351    }
352
353    // --- validate_preflight_expr (admission) ---
354
355    #[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        // A string-valued expression is not a pass/fail predicate.
386        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}