Skip to main content

pgroles_operator/
events.rs

1//! Transition-based Kubernetes Events for `PostgresPolicy` resources.
2//!
3//! Events complement status conditions:
4//! - status remains the source of truth for the current state
5//! - Events surface notable transitions in `kubectl describe`
6//! - OTLP metrics remain the fleet-level observability path
7
8use 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
17/// Publish a request lifecycle Event on the request object itself.
18pub 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
44/// Publish Kubernetes Events for notable status transitions.
45pub 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
58/// Publish a plan lifecycle event on the parent policy.
59pub 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
108/// Types of plan lifecycle events.
109pub enum PlanEventType {
110    /// A new plan was computed.
111    Created { change_count: i32 },
112    /// Approval annotation detected on a plan.
113    Approved,
114    /// Rejection annotation detected on a plan.
115    Rejected,
116    /// Plan execution has started.
117    ApplyStarted,
118    /// Plan execution completed successfully.
119    ApplySucceeded,
120    /// Plan execution failed.
121    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        // A deprecation nag must not re-fire on every reconcile: with the
556        // condition already present, the transition has already been reported.
557        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}