Skip to main content

rusty_stack/orchestrator/
verify.rs

1//! Post-apply verification orchestration.
2//!
3//! This module implements:
4//! - [`VerifyRunner`] — runs verification checks for each successfully-applied component.
5//! - [`VerifySummary`] — partitions results into verified / failed / not-verified.
6//!
7//! # Verification Flow (VAL-UPD-017)
8//!
9//! After the apply phase completes, verification runs for each component
10//! that was applied successfully. Each verification produces a
11//! [`VerificationResult`](crate::core::verification::VerificationResult).
12//!
13//! # Summary Partitioning (VAL-UPD-018)
14//!
15//! The final summary partitions every component into exactly one bucket:
16//! - `verified` — applied and verified successfully
17//! - `failed` — applied but verification failed
18//! - `not_verified` — not applied (held back, blocked, or skipped)
19
20use crate::core::verification::{VerificationCheck, VerificationResult};
21use crate::orchestrator::apply::{ApplyStatus, ApplySummary};
22use serde::{Deserialize, Serialize};
23use std::fmt;
24
25// ---------------------------------------------------------------------------
26// Verify Executor Trait
27// ---------------------------------------------------------------------------
28
29/// Trait for verifying a single component after apply.
30///
31/// Implementations can check binary existence, version matches,
32/// import tests, etc.
33pub trait VerifyExecutor: Send + Sync {
34    /// Verify a single component.
35    ///
36    /// Returns a [`VerificationResult`] with individual checks.
37    fn verify_component(&self, component_id: &str, expected_version: &str) -> VerificationResult;
38}
39
40// ---------------------------------------------------------------------------
41// Default Verify Executor
42// ---------------------------------------------------------------------------
43
44/// A no-op verifier that always returns success.
45/// Used as a default when no real verifier is configured.
46pub struct NoOpVerifyExecutor;
47
48impl VerifyExecutor for NoOpVerifyExecutor {
49    fn verify_component(&self, component_id: &str, _expected_version: &str) -> VerificationResult {
50        VerificationResult::new(
51            component_id,
52            vec![VerificationCheck::passed(
53                "default_check",
54                "no-op verification passed",
55            )],
56        )
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Verify Item
62// ---------------------------------------------------------------------------
63
64/// Verification result for a single component, with apply context.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub struct VerifyItem {
67    /// Component ID.
68    pub component_id: String,
69    /// The apply status that preceded verification.
70    pub apply_status: ApplyStatus,
71    /// The verification result (None if not verified).
72    pub verification_result: Option<VerificationResult>,
73    /// Overall verification status.
74    pub verified: VerifyStatus,
75}
76
77/// Overall verification status for a component.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum VerifyStatus {
81    /// Successfully verified after apply.
82    Verified,
83    /// Verification failed after apply.
84    VerifyFailed,
85    /// Not verified (was not applied).
86    NotVerified,
87}
88
89impl VerifyStatus {
90    /// Human-readable label.
91    pub fn label(self) -> &'static str {
92        match self {
93            VerifyStatus::Verified => "verified",
94            VerifyStatus::VerifyFailed => "verify_failed",
95            VerifyStatus::NotVerified => "not_verified",
96        }
97    }
98}
99
100impl fmt::Display for VerifyStatus {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(self.label())
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Verify Summary
108// ---------------------------------------------------------------------------
109
110/// Summary of the verification phase, partitioning all components.
111///
112/// Every component from the apply phase appears in exactly one bucket:
113/// - `verified` — applied successfully and verification passed
114/// - `failed` — applied but verification failed
115/// - `not_verified` — not applied (held back, blocked, skipped, or apply-failed)
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub struct VerifySummary {
118    /// Components that were verified successfully.
119    pub verified: Vec<VerifyItem>,
120    /// Components that failed verification.
121    pub failed: Vec<VerifyItem>,
122    /// Components that were not verified (not applied).
123    pub not_verified: Vec<VerifyItem>,
124}
125
126impl VerifySummary {
127    /// Create an empty summary.
128    pub fn new() -> Self {
129        Self {
130            verified: Vec::new(),
131            failed: Vec::new(),
132            not_verified: Vec::new(),
133        }
134    }
135
136    /// Total items across all buckets.
137    pub fn total(&self) -> usize {
138        self.verified.len() + self.failed.len() + self.not_verified.len()
139    }
140
141    /// Check that every component is in exactly one bucket.
142    pub fn is_complete_partition(&self) -> bool {
143        let all_ids: HashSet<&str> = self
144            .verified
145            .iter()
146            .chain(self.failed.iter())
147            .chain(self.not_verified.iter())
148            .map(|i| i.component_id.as_str())
149            .collect();
150
151        let total_count = self.verified.len() + self.failed.len() + self.not_verified.len();
152        all_ids.len() == total_count
153    }
154}
155
156impl Default for VerifySummary {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162// ---------------------------------------------------------------------------
163// Verify Runner
164// ---------------------------------------------------------------------------
165
166/// Runs post-apply verification for each successfully-applied component.
167///
168/// # Algorithm
169///
170/// 1. For each item in the apply summary:
171///    - If `Success`: run verification, classify as verified or verify_failed
172///    - If `Failed` / `BlockedByFailure` / `HeldBack` / `Skipped`: mark as not_verified
173/// 2. Produce a `VerifySummary` with complete partitioning.
174pub struct VerifyRunner {
175    /// The verifier to use for each component.
176    executor: Box<dyn VerifyExecutor>,
177}
178
179impl VerifyRunner {
180    /// Create a new verify runner with the given executor.
181    pub fn new(executor: impl VerifyExecutor + 'static) -> Self {
182        Self {
183            executor: Box::new(executor),
184        }
185    }
186
187    /// Create a new verify runner with the default no-op executor.
188    pub fn new_noop() -> Self {
189        Self::new(NoOpVerifyExecutor)
190    }
191
192    /// Run verification for all items in the apply summary.
193    ///
194    /// Returns a `VerifySummary` with complete partitioning.
195    pub fn verify(&self, apply_summary: &ApplySummary) -> VerifySummary {
196        let mut summary = VerifySummary::new();
197
198        // Process successful applies — run verification
199        for item in &apply_summary.success {
200            let result = self
201                .executor
202                .verify_component(&item.component_id, &item.proposed_version);
203
204            let verified = result.success;
205
206            let verify_item = VerifyItem {
207                component_id: item.component_id.clone(),
208                apply_status: ApplyStatus::Success,
209                verification_result: Some(result),
210                verified: if verified {
211                    VerifyStatus::Verified
212                } else {
213                    VerifyStatus::VerifyFailed
214                },
215            };
216
217            if verified {
218                summary.verified.push(verify_item);
219            } else {
220                summary.failed.push(verify_item);
221            }
222        }
223
224        // Process failed, blocked, held_back, skipped — no verification
225        for item in apply_summary
226            .failed
227            .iter()
228            .chain(apply_summary.blocked.iter())
229            .chain(apply_summary.held_back.iter())
230            .chain(apply_summary.skipped.iter())
231        {
232            summary.not_verified.push(VerifyItem {
233                component_id: item.component_id.clone(),
234                apply_status: item.status,
235                verification_result: None,
236                verified: VerifyStatus::NotVerified,
237            });
238        }
239
240        summary
241    }
242}
243
244// Need HashSet for is_complete_partition
245use std::collections::HashSet;
246
247// ===========================================================================
248// Tests
249// ===========================================================================
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::core::verification::{CheckStatus, VerificationCheck};
255    use crate::orchestrator::apply::ApplyStatus;
256
257    // -----------------------------------------------------------------------
258    // Helpers
259    // -----------------------------------------------------------------------
260
261    fn make_apply_summary(
262        success_ids: &[&str],
263        failed_ids: &[&str],
264        held_back_ids: &[&str],
265        blocked_ids: &[&str],
266    ) -> ApplySummary {
267        use crate::orchestrator::apply::ApplyItem;
268        let mut summary = ApplySummary::new();
269        for &id in success_ids {
270            summary
271                .success
272                .push(ApplyItem::new(id, "1.0.0", "1.1.0", ApplyStatus::Success));
273        }
274        for &id in failed_ids {
275            let mut item = ApplyItem::new(id, "1.0.0", "1.1.0", ApplyStatus::Failed);
276            item.error_message = "test failure".to_string();
277            summary.failed.push(item);
278        }
279        for &id in held_back_ids {
280            let mut item = ApplyItem::new(id, "1.0.0", "1.1.0", ApplyStatus::HeldBack);
281            item.was_selected = false;
282            summary.held_back.push(item);
283        }
284        for &id in blocked_ids {
285            let mut item = ApplyItem::new(id, "1.0.0", "1.1.0", ApplyStatus::BlockedByFailure);
286            item.error_message = "blocked by dependency".to_string();
287            summary.blocked.push(item);
288        }
289        summary
290    }
291
292    // -----------------------------------------------------------------------
293    // VAL-UPD-017: Verification runs after apply
294    // -----------------------------------------------------------------------
295
296    #[test]
297    fn test_verify_runs_for_successful_applies() {
298        let apply_summary = make_apply_summary(&["a", "b", "c"], &[], &[], &[]);
299        let runner = VerifyRunner::new_noop();
300        let verify_summary = runner.verify(&apply_summary);
301
302        // All successful applies should be verified
303        assert_eq!(verify_summary.verified.len(), 3);
304        assert_eq!(verify_summary.failed.len(), 0);
305        assert_eq!(verify_summary.not_verified.len(), 0);
306
307        // Each verified item should have a verification result
308        for item in &verify_summary.verified {
309            assert!(item.verification_result.is_some());
310            assert!(item.verification_result.as_ref().unwrap().success);
311        }
312    }
313
314    #[test]
315    fn test_verify_not_run_for_failed_applies() {
316        let apply_summary = make_apply_summary(&[], &["a"], &[], &[]);
317        let runner = VerifyRunner::new_noop();
318        let verify_summary = runner.verify(&apply_summary);
319
320        assert_eq!(verify_summary.verified.len(), 0);
321        assert_eq!(verify_summary.not_verified.len(), 1);
322        assert_eq!(verify_summary.not_verified[0].component_id, "a");
323        assert_eq!(
324            verify_summary.not_verified[0].verified,
325            VerifyStatus::NotVerified
326        );
327    }
328
329    // -----------------------------------------------------------------------
330    // VAL-UPD-018: Summary shows success/failure/holdbacks
331    // -----------------------------------------------------------------------
332
333    #[test]
334    fn test_verify_summary_complete_partitioning() {
335        let apply_summary = make_apply_summary(&["a", "b"], &["c"], &["d"], &["e"]);
336        let runner = VerifyRunner::new_noop();
337        let verify_summary = runner.verify(&apply_summary);
338
339        assert!(verify_summary.is_complete_partition());
340        assert_eq!(verify_summary.total(), 5);
341        assert_eq!(verify_summary.verified.len(), 2);
342        assert_eq!(verify_summary.failed.len(), 0);
343        assert_eq!(verify_summary.not_verified.len(), 3);
344    }
345
346    #[test]
347    fn test_verify_summary_partition_no_overlaps() {
348        let apply_summary = make_apply_summary(&["a"], &["b"], &["c"], &["d"]);
349        let runner = VerifyRunner::new_noop();
350        let verify_summary = runner.verify(&apply_summary);
351
352        let verified_ids: HashSet<&str> = verify_summary
353            .verified
354            .iter()
355            .map(|i| i.component_id.as_str())
356            .collect();
357        let failed_ids: HashSet<&str> = verify_summary
358            .failed
359            .iter()
360            .map(|i| i.component_id.as_str())
361            .collect();
362        let not_verified_ids: HashSet<&str> = verify_summary
363            .not_verified
364            .iter()
365            .map(|i| i.component_id.as_str())
366            .collect();
367
368        // No overlaps
369        assert!(verified_ids.is_disjoint(&failed_ids));
370        assert!(verified_ids.is_disjoint(&not_verified_ids));
371        assert!(failed_ids.is_disjoint(&not_verified_ids));
372
373        // Union equals all items
374        let all: HashSet<&str> = verified_ids
375            .union(&failed_ids)
376            .cloned()
377            .collect::<HashSet<&str>>()
378            .union(&not_verified_ids)
379            .cloned()
380            .collect();
381        assert_eq!(all.len(), 4);
382    }
383
384    // -----------------------------------------------------------------------
385    // Verification failure case
386    // -----------------------------------------------------------------------
387
388    #[test]
389    fn test_verify_failure_after_successful_apply() {
390        // Custom verifier that fails for component "b"
391        struct FailForB;
392        impl VerifyExecutor for FailForB {
393            fn verify_component(
394                &self,
395                component_id: &str,
396                _expected_version: &str,
397            ) -> VerificationResult {
398                if component_id == "b" {
399                    VerificationResult::new(
400                        component_id,
401                        vec![VerificationCheck::failed("import_test", "import failed")],
402                    )
403                } else {
404                    VerificationResult::new(
405                        component_id,
406                        vec![VerificationCheck::passed("ok", "ok")],
407                    )
408                }
409            }
410        }
411
412        let apply_summary = make_apply_summary(&["a", "b", "c"], &[], &[], &[]);
413        let runner = VerifyRunner::new(FailForB);
414        let verify_summary = runner.verify(&apply_summary);
415
416        assert_eq!(verify_summary.verified.len(), 2);
417        assert_eq!(verify_summary.failed.len(), 1);
418        assert_eq!(verify_summary.failed[0].component_id, "b");
419        assert_eq!(
420            verify_summary.failed[0].verified,
421            VerifyStatus::VerifyFailed
422        );
423        assert!(verify_summary.failed[0]
424            .verification_result
425            .as_ref()
426            .unwrap()
427            .checks
428            .iter()
429            .any(|c| c.status == CheckStatus::Failed));
430    }
431
432    // -----------------------------------------------------------------------
433    // Empty apply summary
434    // -----------------------------------------------------------------------
435
436    #[test]
437    fn test_verify_empty_apply_summary() {
438        let apply_summary = ApplySummary::new();
439        let runner = VerifyRunner::new_noop();
440        let verify_summary = runner.verify(&apply_summary);
441
442        assert_eq!(verify_summary.total(), 0);
443        assert!(verify_summary.is_complete_partition());
444    }
445
446    // -----------------------------------------------------------------------
447    // VerifyStatus serde
448    // -----------------------------------------------------------------------
449
450    #[test]
451    fn test_verify_status_serde_roundtrip() {
452        let statuses = [
453            VerifyStatus::Verified,
454            VerifyStatus::VerifyFailed,
455            VerifyStatus::NotVerified,
456        ];
457        for status in &statuses {
458            let json = serde_json::to_string(status).unwrap();
459            let back: VerifyStatus = serde_json::from_str(&json).unwrap();
460            assert_eq!(*status, back);
461        }
462    }
463
464    // -----------------------------------------------------------------------
465    // VerifySummary serde
466    // -----------------------------------------------------------------------
467
468    #[test]
469    fn test_verify_summary_serde_roundtrip() {
470        let apply_summary = make_apply_summary(&["a"], &["b"], &["c"], &[]);
471        let runner = VerifyRunner::new_noop();
472        let verify_summary = runner.verify(&apply_summary);
473
474        let json = serde_json::to_string(&verify_summary).unwrap();
475        let back: VerifySummary = serde_json::from_str(&json).unwrap();
476        assert_eq!(verify_summary, back);
477    }
478
479    // -----------------------------------------------------------------------
480    // VerifyItem serde
481    // -----------------------------------------------------------------------
482
483    #[test]
484    fn test_verify_item_serde_roundtrip() {
485        let item = VerifyItem {
486            component_id: "pytorch".to_string(),
487            apply_status: ApplyStatus::Success,
488            verification_result: Some(VerificationResult::new(
489                "pytorch",
490                vec![VerificationCheck::passed("import", "ok")],
491            )),
492            verified: VerifyStatus::Verified,
493        };
494        let json = serde_json::to_string(&item).unwrap();
495        let back: VerifyItem = serde_json::from_str(&json).unwrap();
496        assert_eq!(item, back);
497    }
498
499    // -----------------------------------------------------------------------
500    // VerifyStatus display
501    // -----------------------------------------------------------------------
502
503    #[test]
504    fn test_verify_status_display() {
505        assert_eq!(format!("{}", VerifyStatus::Verified), "verified");
506        assert_eq!(format!("{}", VerifyStatus::VerifyFailed), "verify_failed");
507        assert_eq!(format!("{}", VerifyStatus::NotVerified), "not_verified");
508    }
509
510    // -----------------------------------------------------------------------
511    // Full flow: apply → verify → summary
512    // -----------------------------------------------------------------------
513
514    #[test]
515    fn test_full_apply_verify_flow() {
516        // Simulate: a succeeds, b fails, c held back
517        let apply_summary = make_apply_summary(&["a"], &["b"], &["c"], &[]);
518        let runner = VerifyRunner::new_noop();
519        let verify_summary = runner.verify(&apply_summary);
520
521        // a verified
522        assert_eq!(verify_summary.verified.len(), 1);
523        assert_eq!(verify_summary.verified[0].component_id, "a");
524
525        // b and c not verified
526        assert_eq!(verify_summary.not_verified.len(), 2);
527        let not_verified_ids: HashSet<&str> = verify_summary
528            .not_verified
529            .iter()
530            .map(|i| i.component_id.as_str())
531            .collect();
532        assert!(not_verified_ids.contains("b"));
533        assert!(not_verified_ids.contains("c"));
534
535        // Complete partition
536        assert!(verify_summary.is_complete_partition());
537        assert_eq!(verify_summary.total(), 3);
538    }
539}