1use 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
28pub 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
47pub 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 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 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 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 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#[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#[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 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}