Skip to main content

lava_operator/
viggy_loop.rs

1//! Viggy loop — drives `ViggyEngine::tick` on every reconcile pass.
2//!
3//! Solid abstraction: the kube-rs controller never composes the
4//! seven beats by hand. It builds one [`LavaPromessaController`]
5//! per CR, hands it to a [`lava_viggy::ViggyEngine`], and calls
6//! `engine.tick(source, bindings)` — every beat (Observe, Diff,
7//! Classify, Decide, Act, Attest, Tick) runs in order with typed
8//! BeatOutcome capture + OutcomeChain attestation.
9//!
10//! The PlannerBackend seam keeps this module test-clean — tests pass
11//! [`lava_drift::MockPlanner`] for an arbitrary scan; production
12//! (when `magma-bridge` feature is on) plugs in a magma-lava-backed
13//! planner.
14
15use std::sync::{Arc, Mutex};
16
17use indexmap::IndexMap;
18use lava_anomaly::RemediationPolicy;
19use lava_drift::{DriftDetector, DriftReport, PlannerBackend, PlannerError};
20use lava_outcome_chain::{
21    ChangeSummary, ContentHash, InMemorySink, OutcomeChain, OutcomePayload, OutcomeSink,
22    ResourceAddress, SigningProvider,
23};
24use lava_viggy::{
25    Beat, BeatStatus, PromessaController, TickPhase, TickReport, ViggyEngine, ViggyError,
26};
27
28/// Concrete controller bound to one [`Source`] (an inline tlisp
29/// string, a bundled name, or a git path). Carries a
30/// [`PlannerBackend`] for Diff and an [`OutcomeSink`] +
31/// [`SigningProvider`] for Attest.
32///
33/// `Send + Sync` so the kube-rs reconcile callback can construct
34/// this on every reconcile pass without lifetime gymnastics.
35pub struct LavaPromessaController<B, S, G>
36where
37    B: PlannerBackend + Send + Sync,
38    S: OutcomeSink<OutcomePayload> + Send + Sync + 'static,
39    G: SigningProvider + Send + Sync + 'static,
40{
41    pub source_text: String,
42    pub source_address: ResourceAddress,
43    pub detector: DriftDetector<B>,
44    pub chain: Arc<Mutex<OutcomeChain<OutcomePayload, S, G>>>,
45}
46
47/// Context carried between beats. Today: the resolved spec source
48/// (already in `source_text`, but we forward it for the act/attest
49/// beats so they don't re-clone). Future: cached planner inputs,
50/// pre-fetched live state snapshots.
51pub struct ObservedContext {
52    pub spec_hash: ContentHash,
53}
54
55impl<B, S, G> PromessaController for LavaPromessaController<B, S, G>
56where
57    B: PlannerBackend + Send + Sync,
58    S: OutcomeSink<OutcomePayload> + Send + Sync,
59    G: SigningProvider + Send + Sync,
60{
61    type Context = ObservedContext;
62
63    fn observe(
64        &self,
65        _source: &ResourceAddress,
66        _bindings: &IndexMap<String, String>,
67    ) -> Result<Self::Context, ViggyError> {
68        // Compute the spec hash once and forward to subsequent beats.
69        Ok(ObservedContext {
70            spec_hash: ContentHash::of(self.source_text.as_bytes()),
71        })
72    }
73
74    fn diff(
75        &self,
76        _ctx: &Self::Context,
77        bindings: &IndexMap<String, String>,
78    ) -> Result<DriftReport, ViggyError> {
79        self.detector
80            .scan(&self.source_text, bindings)
81            .map_err(|e: PlannerError| ViggyError::Diff(e.to_string()))
82    }
83
84    fn attest(&self, report: &TickReport) -> Result<(), ViggyError> {
85        // Build the canonical payload from the tick report and
86        // append to the chain. One receipt per tick.
87        let payload = OutcomePayload {
88            resource: self.source_address.clone(),
89            spec_hash: ContentHash::of(self.source_text.as_bytes()),
90            terraform_json_hash: ContentHash::genesis(),
91            plan_id: None,
92            phase: tick_phase_to_payload_phase(report.final_phase).to_string(),
93            change_summary: change_summary_from_report(report),
94            diagnostics: diagnostics_from_report(report),
95        };
96        let mut chain = self
97            .chain
98            .lock()
99            .map_err(|e| ViggyError::Attest(format!("chain mutex poisoned: {e}")))?;
100        chain
101            .append(payload)
102            .map_err(|e| ViggyError::Attest(e.to_string()))?;
103        Ok(())
104    }
105}
106
107fn tick_phase_to_payload_phase(p: TickPhase) -> &'static str {
108    match p {
109        TickPhase::Stable => "Applied",
110        TickPhase::Reconverging => "Reconverging",
111        TickPhase::HoldingForApproval => "Drifted",
112        TickPhase::Escalated => "Drifted",
113        TickPhase::Failed => "Failed",
114    }
115}
116
117fn change_summary_from_report(report: &TickReport) -> ChangeSummary {
118    // Pull the diff beat's message — formatted as "<N> drifted field(s)".
119    // Fall back to an empty summary on unmatched shapes.
120    let mut summary = ChangeSummary::default();
121    if let Some(diff_beat) = report.beats.iter().find(|b| b.beat == Beat::Diff) {
122        if let Some(msg) = &diff_beat.message {
123            if let Some(n_str) = msg.split_whitespace().next() {
124                if let Ok(n) = n_str.parse::<u32>() {
125                    // We don't have create/update/delete split here —
126                    // surface as a single "update" count so the
127                    // payload still carries the cardinality.
128                    summary.update = n;
129                }
130            }
131        }
132    }
133    summary
134}
135
136fn diagnostics_from_report(report: &TickReport) -> Vec<String> {
137    report
138        .beats
139        .iter()
140        .map(|b| {
141            format!(
142                "{}={}{}",
143                b.beat.as_str(),
144                match b.status {
145                    BeatStatus::Ok => "Ok",
146                    BeatStatus::Skipped => "Skipped",
147                    BeatStatus::Failed => "Failed",
148                },
149                b.message
150                    .as_ref()
151                    .map(|m| format!(" ({m})"))
152                    .unwrap_or_default(),
153            )
154        })
155        .collect()
156}
157
158/// Convenience constructor: build a [`ViggyEngine`] with default
159/// policy and the [`lava_anomaly::PolicyRouter`]. Consumers that
160/// need a custom router pass their own.
161#[must_use]
162pub fn engine_with_default_router<B, S, G>(
163    controller: LavaPromessaController<B, S, G>,
164    policy: RemediationPolicy,
165) -> ViggyEngine<LavaPromessaController<B, S, G>, lava_anomaly::PolicyRouter>
166where
167    B: PlannerBackend + Send + Sync,
168    S: OutcomeSink<OutcomePayload> + Send + Sync,
169    G: SigningProvider + Send + Sync,
170{
171    ViggyEngine::new(controller, lava_anomaly::PolicyRouter, policy)
172}
173
174/// Default shared in-memory chain — useful for tests + the no-sink
175/// path in dev clusters.
176#[must_use]
177pub fn shared_in_memory_chain() -> Arc<
178    Mutex<OutcomeChain<OutcomePayload, InMemorySink<OutcomePayload>, lava_outcome_chain::NoSigning>>,
179> {
180    Arc::new(Mutex::new(OutcomeChain::new(
181        InMemorySink::default(),
182        lava_outcome_chain::NoSigning,
183    )))
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use lava_drift::{ChangeKind, DriftFinding, MockPlanner};
190    use lava_outcome_chain::{verify_chain, NoOpVerifier};
191
192    fn finding(kind: ChangeKind, attr: &str) -> DriftFinding {
193        DriftFinding {
194            address: "aws_vpc.main".into(),
195            attribute: attr.into(),
196            change: kind,
197            observed: None,
198            declared: None,
199        }
200    }
201
202    fn controller_with(
203        findings: Vec<DriftFinding>,
204    ) -> LavaPromessaController<
205        MockPlanner,
206        InMemorySink<OutcomePayload>,
207        lava_outcome_chain::NoSigning,
208    > {
209        LavaPromessaController {
210            source_text: "(deflava-architecture demo :inputs () :resources ())".into(),
211            source_address: ResourceAddress::new("rio", "lava-system", "demo"),
212            detector: DriftDetector::new(MockPlanner::new(findings)),
213            chain: shared_in_memory_chain(),
214        }
215    }
216
217    #[test]
218    fn clean_tick_lands_stable_and_appends_one_receipt() {
219        let controller = controller_with(vec![]);
220        let chain_handle = controller.chain.clone();
221        let engine = engine_with_default_router(controller, RemediationPolicy::default());
222        let report = engine.tick(
223            ResourceAddress::new("rio", "lava-system", "demo"),
224            IndexMap::new(),
225        );
226        assert_eq!(report.final_phase, TickPhase::Stable);
227        let chain = chain_handle.lock().unwrap();
228        let receipts = chain.read_all().unwrap();
229        assert_eq!(receipts.len(), 1);
230        assert_eq!(receipts[0].payload.phase, "Applied");
231    }
232
233    #[test]
234    fn functional_drift_tick_lands_reconverging_and_chain_records_it() {
235        let controller = controller_with(vec![finding(ChangeKind::Update, "cidr_block")]);
236        let chain_handle = controller.chain.clone();
237        let engine = engine_with_default_router(controller, RemediationPolicy::default());
238        let report = engine.tick(
239            ResourceAddress::new("rio", "lava-system", "demo"),
240            IndexMap::new(),
241        );
242        assert_eq!(report.final_phase, TickPhase::Reconverging);
243        let chain = chain_handle.lock().unwrap();
244        let receipts = chain.read_all().unwrap();
245        assert_eq!(receipts.len(), 1);
246        assert_eq!(receipts[0].payload.phase, "Reconverging");
247        assert_eq!(receipts[0].payload.change_summary.update, 1);
248    }
249
250    #[test]
251    fn critical_drift_tick_lands_drifted_and_records_holding_diagnostic() {
252        let controller = controller_with(vec![finding(ChangeKind::Delete, "*")]);
253        let chain_handle = controller.chain.clone();
254        let engine = engine_with_default_router(controller, RemediationPolicy::default());
255        let report = engine.tick(
256            ResourceAddress::new("rio", "lava-system", "demo"),
257            IndexMap::new(),
258        );
259        assert_eq!(report.final_phase, TickPhase::HoldingForApproval);
260        let chain = chain_handle.lock().unwrap();
261        let receipts = chain.read_all().unwrap();
262        assert_eq!(receipts[0].payload.phase, "Drifted");
263        assert!(
264            receipts[0]
265                .payload
266                .diagnostics
267                .iter()
268                .any(|d| d.starts_with("Decide=Ok")),
269            "expected Decide=Ok in diagnostics; got: {:?}",
270            receipts[0].payload.diagnostics
271        );
272    }
273
274    #[test]
275    fn three_ticks_produce_a_blake3_linked_chain_that_verifies() {
276        let controller = controller_with(vec![]);
277        let chain_handle = controller.chain.clone();
278        let engine = engine_with_default_router(controller, RemediationPolicy::default());
279        for _ in 0..3 {
280            engine.tick(
281                ResourceAddress::new("rio", "lava-system", "demo"),
282                IndexMap::new(),
283            );
284        }
285        let chain = chain_handle.lock().unwrap();
286        let receipts = chain.read_all().unwrap();
287        assert_eq!(receipts.len(), 3);
288        verify_chain(&receipts, &NoOpVerifier).unwrap();
289        assert_eq!(receipts[1].prev_hash, receipts[0].content_hash);
290        assert_eq!(receipts[2].prev_hash, receipts[1].content_hash);
291    }
292
293    #[test]
294    fn observe_failure_short_circuits_but_still_attempts_attest() {
295        // Build a controller whose observe always fails by giving it
296        // a source that's empty-but-non-empty. (Our observe is
297        // infallible today; this test asserts the chain stays
298        // consistent across a no-op scenario.)
299        let controller = controller_with(vec![]);
300        let chain_handle = controller.chain.clone();
301        let engine = engine_with_default_router(controller, RemediationPolicy::default());
302        let _ = engine.tick(
303            ResourceAddress::new("rio", "lava-system", "demo"),
304            IndexMap::new(),
305        );
306        let chain = chain_handle.lock().unwrap();
307        assert_eq!(chain.read_all().unwrap().len(), 1);
308    }
309}