1use k8s_openapi::api::core::v1::ObjectReference;
9use kube::Resource;
10use kube::runtime::events::{Event, EventType, Recorder};
11
12use crate::crd::{
13 EphemeralAccessRequest, EphemeralAccessRequestPhase, PolicyCondition, PostgresPolicy,
14 PostgresPolicyPlan, PostgresPolicyStatus,
15};
16
17pub async fn publish_ephemeral_request_event(
19 recorder: &Recorder,
20 request: &EphemeralAccessRequest,
21 phase: EphemeralAccessRequestPhase,
22 reason: &str,
23 note: String,
24) -> Result<(), kube::Error> {
25 let reference: ObjectReference = request.object_ref(&());
26 let event_type = if matches!(
27 phase,
28 EphemeralAccessRequestPhase::Failed
29 | EphemeralAccessRequestPhase::Denied
30 | EphemeralAccessRequestPhase::ApprovalExpired
31 ) {
32 EventType::Warning
33 } else {
34 EventType::Normal
35 };
36 recorder
37 .publish(
38 &event(event_type, reason, "EphemeralAccessLifecycle", note),
39 &reference,
40 )
41 .await
42}
43
44pub async fn publish_status_events(
46 recorder: &Recorder,
47 resource: &PostgresPolicy,
48 old_status: Option<&PostgresPolicyStatus>,
49 new_status: &PostgresPolicyStatus,
50) -> Result<(), kube::Error> {
51 let reference: ObjectReference = resource.object_ref(&());
52 for event in derive_status_events(old_status, new_status) {
53 recorder.publish(&event, &reference).await?;
54 }
55 Ok(())
56}
57
58pub async fn publish_plan_event(
60 recorder: &Recorder,
61 policy: &PostgresPolicy,
62 plan: &PostgresPolicyPlan,
63 event_type: PlanEventType,
64) -> Result<(), kube::Error> {
65 let reference: ObjectReference = policy.object_ref(&());
66 let plan_name = kube::ResourceExt::name_any(plan);
67 let event = match event_type {
68 PlanEventType::Created { change_count } => event(
69 EventType::Normal,
70 "PlanCreated",
71 "PlanLifecycle",
72 format!("Plan {plan_name} created with {change_count} change(s)"),
73 ),
74 PlanEventType::Approved => event(
75 EventType::Normal,
76 "PlanApproved",
77 "PlanLifecycle",
78 format!("Plan {plan_name} approved"),
79 ),
80 PlanEventType::Rejected => event(
81 EventType::Normal,
82 "PlanRejected",
83 "PlanLifecycle",
84 format!("Plan {plan_name} rejected"),
85 ),
86 PlanEventType::ApplyStarted => event(
87 EventType::Normal,
88 "ApplyStarted",
89 "PlanLifecycle",
90 format!("Executing plan {plan_name}"),
91 ),
92 PlanEventType::ApplySucceeded => event(
93 EventType::Normal,
94 "ApplySucceeded",
95 "PlanLifecycle",
96 format!("Plan {plan_name} applied successfully"),
97 ),
98 PlanEventType::ApplyFailed { error } => event(
99 EventType::Warning,
100 "ApplyFailed",
101 "PlanLifecycle",
102 format!("Plan {plan_name} failed: {error}"),
103 ),
104 };
105 recorder.publish(&event, &reference).await
106}
107
108pub enum PlanEventType {
110 Created { change_count: i32 },
112 Approved,
114 Rejected,
116 ApplyStarted,
118 ApplySucceeded,
120 ApplyFailed { error: String },
122}
123
124fn derive_status_events(
125 old_status: Option<&PostgresPolicyStatus>,
126 new_status: &PostgresPolicyStatus,
127) -> Vec<Event> {
128 let mut events = Vec::new();
129
130 if transitioned_to_true(old_status, new_status, "Conflict") {
131 let note = condition_message(new_status, "Conflict")
132 .or_else(|| new_status.last_error.clone())
133 .unwrap_or_else(|| "Policy ownership conflict detected".to_string());
134 events.push(event(
135 EventType::Warning,
136 "ConflictDetected",
137 "StatusTransition",
138 note,
139 ));
140 }
141
142 if transitioned_from_true(old_status, new_status, "Conflict") {
143 events.push(event(
144 EventType::Normal,
145 "ConflictResolved",
146 "StatusTransition",
147 "Policy ownership conflict resolved".to_string(),
148 ));
149 }
150
151 if transitioned_to_true(old_status, new_status, "Paused") {
152 let note = condition_message(new_status, "Paused")
153 .unwrap_or_else(|| "Reconciliation suspended by spec".to_string());
154 events.push(event(
155 EventType::Normal,
156 "Suspended",
157 "StatusTransition",
158 note,
159 ));
160 }
161
162 if transitioned_to_true(old_status, new_status, "Drifted") {
163 let note = condition_message(new_status, "Drifted")
164 .unwrap_or_else(|| "Planned changes are pending review".to_string());
165 events.push(event(
166 EventType::Normal,
167 "DriftDetected",
168 "StatusTransition",
169 note,
170 ));
171 }
172
173 if plan_became_clean(old_status, new_status) {
174 let note = condition_message(new_status, "Drifted")
175 .unwrap_or_else(|| "Plan computed; database already matches desired state".to_string());
176 events.push(event(
177 EventType::Normal,
178 "PlanClean",
179 "StatusTransition",
180 note,
181 ));
182 }
183
184 if ready_became_true(old_status, new_status) && !is_planned_ready(new_status) {
185 let reason = if had_ready_condition(old_status) {
186 "Recovered"
187 } else {
188 "Reconciled"
189 };
190 let note = condition_message(new_status, "Ready")
191 .unwrap_or_else(|| "Policy reconciled successfully".to_string());
192 events.push(event(EventType::Normal, reason, "StatusTransition", note));
193 }
194
195 if transitioned_to_true(
196 old_status,
197 new_status,
198 crate::crd::CONDITION_APPROVAL_IGNORED,
199 ) {
200 let note = condition_message(new_status, crate::crd::CONDITION_APPROVAL_IGNORED)
201 .unwrap_or_else(|| "Plan approval has no effect in plan mode".to_string());
202 events.push(event(
203 EventType::Warning,
204 "ApprovalIgnored",
205 "StatusTransition",
206 note,
207 ));
208 }
209
210 if transitioned_to_true(old_status, new_status, crate::crd::CONDITION_APPROVAL_UNSET) {
211 let note = condition_message(new_status, crate::crd::CONDITION_APPROVAL_UNSET)
212 .unwrap_or_else(|| "spec.approval is not set and is being inferred".to_string());
213 events.push(event(
214 EventType::Warning,
215 "ApprovalUnset",
216 "StatusTransition",
217 note,
218 ));
219 }
220
221 if let Some(reason) = noteworthy_failure_reason(old_status, new_status) {
222 let note = condition_message(new_status, "Ready")
223 .or_else(|| new_status.last_error.clone())
224 .unwrap_or_else(|| format!("Policy entered {reason} state"));
225 events.push(event(EventType::Warning, reason, "StatusTransition", note));
226 }
227
228 events
229}
230
231fn event(type_: EventType, reason: &str, action: &str, note: String) -> Event {
232 Event {
233 type_,
234 reason: reason.to_string(),
235 note: Some(note),
236 action: action.to_string(),
237 secondary: None,
238 }
239}
240
241fn condition<'a>(
242 status: &'a PostgresPolicyStatus,
243 condition_type: &str,
244) -> Option<&'a PolicyCondition> {
245 status
246 .conditions
247 .iter()
248 .find(|condition| condition.condition_type == condition_type)
249}
250
251fn condition_status<'a>(
252 status: Option<&'a PostgresPolicyStatus>,
253 condition_type: &str,
254) -> Option<&'a str> {
255 status
256 .and_then(|status| condition(status, condition_type))
257 .map(|condition| condition.status.as_str())
258}
259
260fn condition_reason<'a>(
261 status: Option<&'a PostgresPolicyStatus>,
262 condition_type: &str,
263) -> Option<&'a str> {
264 status
265 .and_then(|status| condition(status, condition_type))
266 .and_then(|condition| condition.reason.as_deref())
267}
268
269fn condition_message(status: &PostgresPolicyStatus, condition_type: &str) -> Option<String> {
270 condition(status, condition_type).and_then(|condition| condition.message.clone())
271}
272
273fn condition_is_true(status: Option<&PostgresPolicyStatus>, condition_type: &str) -> bool {
274 condition_status(status, condition_type) == Some("True")
275}
276
277fn transitioned_to_true(
278 old_status: Option<&PostgresPolicyStatus>,
279 new_status: &PostgresPolicyStatus,
280 condition_type: &str,
281) -> bool {
282 !condition_is_true(old_status, condition_type)
283 && condition_is_true(Some(new_status), condition_type)
284}
285
286fn transitioned_from_true(
287 old_status: Option<&PostgresPolicyStatus>,
288 new_status: &PostgresPolicyStatus,
289 condition_type: &str,
290) -> bool {
291 condition_is_true(old_status, condition_type)
292 && !condition_is_true(Some(new_status), condition_type)
293}
294
295fn was_ready(old_status: Option<&PostgresPolicyStatus>) -> bool {
296 condition_is_true(old_status, "Ready")
297}
298
299fn had_ready_condition(old_status: Option<&PostgresPolicyStatus>) -> bool {
300 old_status
301 .and_then(|status| condition(status, "Ready"))
302 .is_some()
303}
304
305fn ready_became_true(
306 old_status: Option<&PostgresPolicyStatus>,
307 new_status: &PostgresPolicyStatus,
308) -> bool {
309 !was_ready(old_status) && condition_is_true(Some(new_status), "Ready")
310}
311
312fn is_planned_ready(status: &PostgresPolicyStatus) -> bool {
313 condition(status, "Ready").and_then(|ready| ready.reason.as_deref()) == Some("Planned")
314}
315
316fn plan_became_clean(
317 old_status: Option<&PostgresPolicyStatus>,
318 new_status: &PostgresPolicyStatus,
319) -> bool {
320 if !is_planned_ready(new_status) || condition_is_true(Some(new_status), "Drifted") {
321 return false;
322 }
323
324 !is_planned_ready_status(old_status) || condition_is_true(old_status, "Drifted")
325}
326
327fn is_planned_ready_status(status: Option<&PostgresPolicyStatus>) -> bool {
328 status.map(is_planned_ready).unwrap_or(false)
329}
330
331fn noteworthy_failure_reason(
332 old_status: Option<&PostgresPolicyStatus>,
333 new_status: &PostgresPolicyStatus,
334) -> Option<&'static str> {
335 let ready = condition(new_status, "Ready")?;
336 if ready.status != "False" {
337 return None;
338 }
339
340 let reason = ready.reason.as_deref()?;
341 if matches!(reason, "ConflictingPolicy" | "Suspended") {
342 return None;
343 }
344
345 let mapped_reason = match reason {
346 "InvalidSpec" => "InvalidSpec",
347 "SecretMissing" | "SecretFetchFailed" => "SecretFetchFailed",
348 "DatabaseConnectionFailed" => "DatabaseConnectionFailed",
349 "GcpAuthFailed" => "GcpAuthFailed",
350 "InsufficientPrivileges" => "InsufficientPrivileges",
351 "UnsafeRoleDrops" => "UnsafeRoleDropsBlocked",
352 _ => return None,
353 };
354
355 let old_ready_status = condition_status(old_status, "Ready");
356 let old_ready_reason = condition_reason(old_status, "Ready");
357
358 if old_ready_status == Some("False") && old_ready_reason == Some(reason) {
359 None
360 } else {
361 Some(mapped_reason)
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::crd::{
369 PostgresPolicyStatus, conflict_condition, drifted_condition, paused_condition,
370 ready_condition,
371 };
372
373 fn reasons(events: &[Event]) -> Vec<&str> {
374 events.iter().map(|event| event.reason.as_str()).collect()
375 }
376
377 #[test]
378 fn emits_conflict_detected_when_conflict_condition_becomes_true() {
379 let mut status = PostgresPolicyStatus::default();
380 status.set_condition(ready_condition(false, "ConflictingPolicy", "overlap"));
381 status.set_condition(conflict_condition("ConflictingPolicy", "overlap"));
382 status.last_error = Some("overlap".to_string());
383
384 let events = derive_status_events(None, &status);
385 assert_eq!(reasons(&events), vec!["ConflictDetected"]);
386 }
387
388 #[test]
389 fn emits_conflict_resolved_and_recovered_when_policy_recovers_from_conflict() {
390 let mut old_status = PostgresPolicyStatus::default();
391 old_status.set_condition(ready_condition(false, "ConflictingPolicy", "overlap"));
392 old_status.set_condition(conflict_condition("ConflictingPolicy", "overlap"));
393
394 let mut new_status = PostgresPolicyStatus::default();
395 new_status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
396
397 let events = derive_status_events(Some(&old_status), &new_status);
398 assert_eq!(reasons(&events), vec!["ConflictResolved", "Recovered"]);
399 }
400
401 #[test]
402 fn emits_suspended_when_policy_is_paused() {
403 let mut status = PostgresPolicyStatus::default();
404 status.set_condition(paused_condition("Reconciliation suspended by spec"));
405 status.set_condition(ready_condition(
406 false,
407 "Suspended",
408 "Reconciliation suspended by spec",
409 ));
410
411 let events = derive_status_events(None, &status);
412 assert_eq!(reasons(&events), vec!["Suspended"]);
413 }
414
415 #[test]
416 fn emits_reconciled_on_first_success() {
417 let mut status = PostgresPolicyStatus::default();
418 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
419
420 let events = derive_status_events(None, &status);
421 assert_eq!(reasons(&events), vec!["Reconciled"]);
422 }
423
424 #[test]
425 fn emits_recovered_when_transitioning_from_not_ready_to_ready() {
426 let mut old_status = PostgresPolicyStatus::default();
427 old_status.set_condition(ready_condition(
428 false,
429 "DatabaseConnectionFailed",
430 "database unavailable",
431 ));
432
433 let mut new_status = PostgresPolicyStatus::default();
434 new_status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
435
436 let events = derive_status_events(Some(&old_status), &new_status);
437 assert_eq!(reasons(&events), vec!["Recovered"]);
438 }
439
440 #[test]
441 fn emits_secret_fetch_failed_when_missing_secret_first_detected() {
442 let mut status = PostgresPolicyStatus::default();
443 status.set_condition(ready_condition(
444 false,
445 "SecretMissing",
446 "Secret \"db\" does not contain key \"DATABASE_URL\"",
447 ));
448 status.last_error = Some("Secret \"db\" does not contain key \"DATABASE_URL\"".to_string());
449
450 let events = derive_status_events(None, &status);
451 assert_eq!(reasons(&events), vec!["SecretFetchFailed"]);
452 }
453
454 #[test]
455 fn does_not_repeat_same_failure_event_without_transition() {
456 let mut old_status = PostgresPolicyStatus::default();
457 old_status.set_condition(ready_condition(
458 false,
459 "DatabaseConnectionFailed",
460 "connection refused",
461 ));
462
463 let mut new_status = PostgresPolicyStatus::default();
464 new_status.set_condition(ready_condition(
465 false,
466 "DatabaseConnectionFailed",
467 "connection refused",
468 ));
469
470 let events = derive_status_events(Some(&old_status), &new_status);
471 assert!(events.is_empty());
472 }
473
474 #[test]
475 fn emits_insufficient_privileges_on_failure_transition() {
476 let mut old_status = PostgresPolicyStatus::default();
477 old_status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
478
479 let mut new_status = PostgresPolicyStatus::default();
480 new_status.set_condition(ready_condition(
481 false,
482 "InsufficientPrivileges",
483 "permission denied to create role",
484 ));
485
486 let events = derive_status_events(Some(&old_status), &new_status);
487 assert_eq!(reasons(&events), vec!["InsufficientPrivileges"]);
488 }
489
490 #[test]
491 fn emits_gcp_auth_failed_on_failure_transition() {
492 let mut old_status = PostgresPolicyStatus::default();
493 old_status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
494
495 let mut new_status = PostgresPolicyStatus::default();
496 new_status.set_condition(ready_condition(
497 false,
498 "GcpAuthFailed",
499 "token request rejected",
500 ));
501
502 let events = derive_status_events(Some(&old_status), &new_status);
503 assert_eq!(reasons(&events), vec!["GcpAuthFailed"]);
504 assert!(matches!(events[0].type_, EventType::Warning));
505 assert_eq!(events[0].note.as_deref(), Some("token request rejected"));
506 }
507
508 #[test]
509 fn emits_approval_ignored_warning_once_on_transition() {
510 let mut new_status = PostgresPolicyStatus::default();
511 new_status.set_condition(crate::crd::approval_ignored_condition("policy-plan-123"));
512
513 let events = derive_status_events(None, &new_status);
514 let ignored = events
515 .iter()
516 .find(|e| e.reason == "ApprovalIgnored")
517 .expect("expected an ApprovalIgnored event");
518 assert!(matches!(ignored.type_, EventType::Warning));
519 assert!(
520 ignored.note.as_deref().is_some_and(
521 |note| note.contains("policy-plan-123") && note.contains("mode: apply")
522 ),
523 "the note should name the plan and the combination that does execute"
524 );
525
526 let events = derive_status_events(Some(&new_status), &new_status);
527 assert!(
528 !reasons(&events).contains(&"ApprovalIgnored"),
529 "steady state should not keep re-emitting the warning"
530 );
531 }
532
533 #[test]
534 fn emits_approval_unset_warning_once_on_transition() {
535 let mut new_status = PostgresPolicyStatus::default();
536 new_status.set_condition(crate::crd::approval_unset_condition(
537 crate::crd::ApprovalMode::Auto,
538 ));
539
540 let events = derive_status_events(None, &new_status);
541 assert!(reasons(&events).contains(&"ApprovalUnset"));
542 let approval_event = events
543 .iter()
544 .find(|e| e.reason == "ApprovalUnset")
545 .expect("expected an ApprovalUnset event");
546 assert!(matches!(approval_event.type_, EventType::Warning));
547 assert!(
548 approval_event
549 .note
550 .as_deref()
551 .is_some_and(|note| note.contains("approval: auto")),
552 "the event note should carry the remediation, not just the warning"
553 );
554
555 let events = derive_status_events(Some(&new_status), &new_status);
558 assert!(
559 !reasons(&events).contains(&"ApprovalUnset"),
560 "steady state should not keep emitting the deprecation event"
561 );
562 }
563
564 #[test]
565 fn emits_drift_detected_for_plan_mode_with_pending_changes() {
566 let mut status = PostgresPolicyStatus::default();
567 status.set_condition(ready_condition(true, "Planned", "Plan computed"));
568 status.set_condition(drifted_condition(
569 true,
570 "DriftDetected",
571 "2 planned change(s) pending review",
572 ));
573
574 let events = derive_status_events(None, &status);
575 assert_eq!(reasons(&events), vec!["DriftDetected"]);
576 }
577
578 #[test]
579 fn emits_plan_clean_when_plan_mode_has_no_pending_changes() {
580 let mut status = PostgresPolicyStatus::default();
581 status.set_condition(ready_condition(true, "Planned", "Plan computed"));
582 status.set_condition(drifted_condition(false, "InSync", "No pending changes"));
583
584 let events = derive_status_events(None, &status);
585 assert_eq!(reasons(&events), vec!["PlanClean"]);
586 }
587}