Skip to main content

pgroles_operator/
reconciler.rs

1//! Reconciliation logic for `PostgresPolicy` custom resources.
2//!
3//! Implements the core reconcile loop: read desired state from the CR,
4//! inspect current state from the database, compute diff, and apply changes.
5//!
6//! Reconciliation is serialized per database target to prevent overlapping
7//! inspect/diff/apply cycles:
8//!
9//! 1. **In-process lock** — [`OperatorContext::try_lock_database`] prevents
10//!    concurrent reconciles within the same operator replica.
11//! 2. **PostgreSQL advisory lock** — [`crate::advisory::try_acquire`] prevents
12//!    concurrent operations across multiple operator replicas.
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use crate::events::{PlanEventType, publish_plan_event, publish_status_events};
18use kube::ResourceExt;
19use kube::api::{Api, Patch, PatchParams};
20use kube::runtime::controller::Action;
21use kube::runtime::finalizer::{self, Event as FinalizerEvent};
22use tracing::info;
23
24use crate::context::{ContextError, OperatorContext};
25use crate::crd::{
26    ChangeSummary, DatabaseIdentity, PolicyMode, PostgresPolicy, PostgresPolicyPlan,
27    PostgresPolicyStatus, conflict_condition, degraded_condition, drifted_condition,
28    paused_condition, ready_condition, reconciling_condition,
29};
30
31/// Finalizer name for PostgresPolicy resources.
32const FINALIZER: &str = "pgroles.io/finalizer";
33
34/// Default requeue interval when no interval is specified on the CR.
35const DEFAULT_REQUEUE_SECS: u64 = 300; // 5 minutes
36
37/// Base requeue delay when lock contention is detected.
38const LOCK_CONTENTION_BASE_SECS: u64 = 10;
39
40/// Maximum jitter added to the base requeue delay on lock contention.
41const LOCK_CONTENTION_JITTER_SECS: u64 = 20;
42
43/// Base requeue delay when transient operational failures occur.
44const TRANSIENT_BACKOFF_BASE_SECS: u64 = 5;
45
46/// Maximum requeue delay for transient operational failures.
47const TRANSIENT_BACKOFF_MAX_SECS: u64 = 300;
48
49/// SQLSTATE returned by PostgreSQL for insufficient privileges.
50const SQLSTATE_INSUFFICIENT_PRIVILEGE: &str = "42501";
51const SQLSTATE_INVALID_SCHEMA_NAME: &str = "3F000";
52const SQLSTATE_UNDEFINED_TABLE: &str = "42P01";
53const SQLSTATE_UNDEFINED_FUNCTION: &str = "42883";
54const SQLSTATE_UNDEFINED_OBJECT: &str = "42704";
55
56/// Maximum amount of rendered planned SQL stored in status.
57const MAX_PLANNED_SQL_STATUS_BYTES: usize = 16 * 1024;
58
59enum ReconcileOutcome {
60    Reconciled,
61    Planned,
62    Suspended,
63    Conflict,
64    LockContention,
65}
66
67impl ReconcileOutcome {
68    fn result(&self) -> &'static str {
69        match self {
70            ReconcileOutcome::Reconciled => "success",
71            ReconcileOutcome::Planned => "planned",
72            ReconcileOutcome::Suspended => "suspended",
73            ReconcileOutcome::Conflict => "conflict",
74            ReconcileOutcome::LockContention => "contention",
75        }
76    }
77
78    fn reason(&self) -> &'static str {
79        match self {
80            ReconcileOutcome::Reconciled => "Reconciled",
81            ReconcileOutcome::Planned => "Planned",
82            ReconcileOutcome::Suspended => "Suspended",
83            ReconcileOutcome::Conflict => "ConflictingPolicy",
84            ReconcileOutcome::LockContention => "LockContention",
85        }
86    }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum RetryClass {
91    Slow,
92    LockContention,
93    Transient,
94}
95
96/// Errors that can occur during reconciliation.
97#[derive(Debug, thiserror::Error)]
98pub enum ReconcileError {
99    #[error("context error: {0}")]
100    Context(#[from] Box<ContextError>),
101
102    #[error("manifest expansion error: {0}")]
103    ManifestExpansion(#[from] pgroles_core::manifest::ManifestError),
104
105    #[error("database inspection error: {0}")]
106    Inspect(#[from] pgroles_inspect::InspectError),
107
108    #[error("SQL execution error: {0}")]
109    SqlExec(#[from] sqlx::Error),
110
111    #[error("{0}")]
112    UnsafeRoleDrops(String),
113
114    #[error("Kubernetes API error: {0}")]
115    Kube(#[from] kube::Error),
116
117    #[error("resource has no namespace")]
118    NoNamespace,
119
120    #[error("invalid interval \"{0}\": {1}")]
121    InvalidInterval(String, String),
122
123    #[error("invalid spec: {0}")]
124    InvalidSpec(String),
125
126    #[error(
127        "policy references objects that do not exist in target database: {0}. Either create \
128         the missing objects, remove them from the policy, or verify the policy is pointing at \
129         the intended database."
130    )]
131    MissingDatabaseObjects(String),
132
133    #[error("{0}")]
134    UnsatisfiableWildcardGrant(String),
135
136    #[error("{0}")]
137    ConflictingPolicy(String),
138
139    #[error("lock contention on database \"{0}\": {1}")]
140    LockContention(String, String),
141
142    #[error("Secret \"{secret}\" key \"{key}\" for role \"{role}\" password is empty")]
143    EmptyPasswordSecret {
144        role: String,
145        secret: String,
146        key: String,
147    },
148
149    #[error("password generation error: {0}")]
150    PasswordGeneration(#[from] Box<crate::password::PasswordError>),
151
152    #[error("plan SQL storage error: {0}")]
153    PlanSqlStorage(String),
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157struct ResolvedPassword {
158    cleartext: String,
159    source_version: String,
160}
161
162/// Parse a duration string like "5m", "1h", "30s", "2h30m".
163fn parse_interval(interval: &str) -> Result<Duration, ReconcileError> {
164    let interval = interval.trim();
165    if interval.is_empty() {
166        return Ok(Duration::from_secs(DEFAULT_REQUEUE_SECS));
167    }
168
169    let mut total_secs: u64 = 0;
170    let mut current_num = String::new();
171
172    for ch in interval.chars() {
173        if ch.is_ascii_digit() {
174            current_num.push(ch);
175        } else {
176            let num: u64 = current_num.parse().map_err(|_| {
177                ReconcileError::InvalidInterval(
178                    interval.to_string(),
179                    format!("invalid number before '{ch}'"),
180                )
181            })?;
182            current_num.clear();
183
184            match ch {
185                'h' => total_secs += num * 3600,
186                'm' => total_secs += num * 60,
187                's' => total_secs += num,
188                _ => {
189                    return Err(ReconcileError::InvalidInterval(
190                        interval.to_string(),
191                        format!("unknown unit '{ch}'"),
192                    ));
193                }
194            }
195        }
196    }
197
198    // If there's a trailing number with no unit, treat as seconds.
199    if !current_num.is_empty() {
200        let num: u64 = current_num.parse().map_err(|_| {
201            ReconcileError::InvalidInterval(interval.to_string(), "trailing number".to_string())
202        })?;
203        total_secs += num;
204    }
205
206    if total_secs == 0 {
207        return Ok(Duration::from_secs(DEFAULT_REQUEUE_SECS));
208    }
209
210    Ok(Duration::from_secs(total_secs))
211}
212
213/// Top-level reconcile entry point called by the kube-rs controller runtime.
214///
215/// Uses the finalizer pattern for cleanup on deletion.
216pub async fn reconcile(
217    resource: Arc<PostgresPolicy>,
218    ctx: Arc<OperatorContext>,
219) -> Result<Action, finalizer::Error<ReconcileError>> {
220    let api: Api<PostgresPolicy> = Api::namespaced(
221        ctx.kube_client.clone(),
222        resource.namespace().as_deref().unwrap_or("default"),
223    );
224
225    finalizer::finalizer(&api, FINALIZER, resource, |event| async {
226        match event {
227            FinalizerEvent::Apply(resource) => reconcile_apply(&resource, &ctx).await,
228            FinalizerEvent::Cleanup(resource) => reconcile_cleanup(&resource, &ctx).await,
229        }
230    })
231    .await
232}
233
234/// Error handler — called when reconcile returns an error.
235pub fn error_policy(
236    resource: Arc<PostgresPolicy>,
237    error: &finalizer::Error<ReconcileError>,
238    _ctx: Arc<OperatorContext>,
239) -> Action {
240    retry_action(&resource, error)
241}
242
243fn retry_action(resource: &PostgresPolicy, error: &finalizer::Error<ReconcileError>) -> Action {
244    match retry_class(error) {
245        RetryClass::LockContention => {
246            if let finalizer::Error::ApplyFailed(ReconcileError::LockContention(db, reason)) = error
247            {
248                tracing::info!(database = %db, reason = %reason, "requeuing due to lock contention");
249            }
250            requeue_with_jitter()
251        }
252        RetryClass::Slow => {
253            let delay = slow_retry_delay(resource);
254            tracing::info!(
255                delay_secs = delay.as_secs(),
256                error = %error,
257                "requeuing on normal interval for non-transient failure"
258            );
259            Action::requeue(delay)
260        }
261        RetryClass::Transient => {
262            let attempts = next_transient_failure_count(resource);
263            let delay = transient_backoff_delay(attempts);
264            tracing::warn!(
265                attempts,
266                delay_secs = delay.as_secs(),
267                error = %error,
268                "requeuing with exponential backoff after transient failure"
269            );
270            Action::requeue(delay)
271        }
272    }
273}
274
275/// Compute a requeue delay with jitter for lock contention back-off.
276fn requeue_with_jitter() -> Action {
277    let delay = jitter_delay();
278    tracing::debug!(delay_secs = delay.as_secs(), "requeue with jitter");
279    Action::requeue(delay)
280}
281
282/// Compute a jittered delay for lock contention back-off.
283///
284/// Returns a [`Duration`] in the range
285/// `[LOCK_CONTENTION_BASE_SECS, LOCK_CONTENTION_BASE_SECS + LOCK_CONTENTION_JITTER_SECS]`.
286fn jitter_delay() -> Duration {
287    // Simple jitter: base + pseudo-random portion of the jitter window.
288    // We combine subsecond nanos with a hash of the thread ID for better
289    // entropy when multiple reconciles hit contention simultaneously.
290    let nanos = std::time::SystemTime::now()
291        .duration_since(std::time::UNIX_EPOCH)
292        .unwrap_or_default()
293        .subsec_nanos();
294    let thread_entropy = {
295        use std::hash::{Hash, Hasher};
296        let mut hasher = std::collections::hash_map::DefaultHasher::new();
297        std::thread::current().id().hash(&mut hasher);
298        hasher.finish() as u32
299    };
300    let jitter_secs = ((nanos ^ thread_entropy) as u64) % (LOCK_CONTENTION_JITTER_SECS + 1);
301    Duration::from_secs(LOCK_CONTENTION_BASE_SECS + jitter_secs)
302}
303
304fn transient_backoff_delay(attempts: u32) -> Duration {
305    let exponent = attempts.saturating_sub(1).min(10);
306    let base_delay = TRANSIENT_BACKOFF_BASE_SECS
307        .saturating_mul(1_u64 << exponent)
308        .min(TRANSIENT_BACKOFF_MAX_SECS);
309    let remaining_headroom = TRANSIENT_BACKOFF_MAX_SECS.saturating_sub(base_delay);
310    let jitter_window = remaining_headroom.min((base_delay / 2).max(1));
311    let jitter_secs = if jitter_window == 0 {
312        0
313    } else {
314        pseudo_random_window(jitter_window)
315    };
316    Duration::from_secs((base_delay + jitter_secs).min(TRANSIENT_BACKOFF_MAX_SECS))
317}
318
319fn pseudo_random_window(window_secs: u64) -> u64 {
320    if window_secs == 0 {
321        return 0;
322    }
323    let nanos = std::time::SystemTime::now()
324        .duration_since(std::time::UNIX_EPOCH)
325        .unwrap_or_default()
326        .subsec_nanos();
327    let thread_entropy = {
328        use std::hash::{Hash, Hasher};
329        let mut hasher = std::collections::hash_map::DefaultHasher::new();
330        std::thread::current().id().hash(&mut hasher);
331        hasher.finish() as u32
332    };
333    ((nanos ^ thread_entropy) as u64) % (window_secs + 1)
334}
335
336fn retry_class(error: &finalizer::Error<ReconcileError>) -> RetryClass {
337    match error {
338        finalizer::Error::ApplyFailed(reconcile_error) => {
339            retry_class_for_reconcile_error(reconcile_error)
340        }
341        finalizer::Error::CleanupFailed(_)
342        | finalizer::Error::AddFinalizer(_)
343        | finalizer::Error::RemoveFinalizer(_)
344        | finalizer::Error::UnnamedObject
345        | finalizer::Error::InvalidFinalizer => RetryClass::Transient,
346    }
347}
348
349fn retry_class_for_reconcile_error(error: &ReconcileError) -> RetryClass {
350    match error {
351        ReconcileError::LockContention(_, _) => RetryClass::LockContention,
352        ReconcileError::ManifestExpansion(_)
353        | ReconcileError::InvalidInterval(_, _)
354        | ReconcileError::InvalidSpec(_)
355        | ReconcileError::MissingDatabaseObjects(_)
356        | ReconcileError::UnsatisfiableWildcardGrant(_)
357        | ReconcileError::ConflictingPolicy(_)
358        | ReconcileError::UnsafeRoleDrops(_)
359        | ReconcileError::EmptyPasswordSecret { .. }
360        | ReconcileError::NoNamespace
361        | ReconcileError::PlanSqlStorage(_) => RetryClass::Slow,
362        ReconcileError::PasswordGeneration(err) => {
363            if err.is_transient() {
364                RetryClass::Transient
365            } else {
366                RetryClass::Slow
367            }
368        }
369        ReconcileError::Context(context) => match context.as_ref() {
370            ContextError::SecretMissing { .. } => RetryClass::Slow,
371            ContextError::SecretFetch { .. } => {
372                if context.is_secret_fetch_non_transient() {
373                    RetryClass::Slow
374                } else {
375                    RetryClass::Transient
376                }
377            }
378            ContextError::GcpAuthRejected { .. } | ContextError::GcpAuthInvalidResponse { .. } => {
379                if context.is_gcp_auth_non_transient() {
380                    RetryClass::Slow
381                } else {
382                    RetryClass::Transient
383                }
384            }
385            ContextError::GcpAuthHttp { .. } => RetryClass::Transient,
386            ContextError::DatabaseConnect { .. } => RetryClass::Transient,
387            // `SET ROLE "<role>"` failing on a freshly-connected session is
388            // a permission/config issue, not a transient connectivity blip.
389            ContextError::SetRoleFailed { .. } => RetryClass::Slow,
390            ContextError::EmptyResolvedValue { .. }
391            | ContextError::InvalidResolvedSslMode { .. } => RetryClass::Slow,
392        },
393        ReconcileError::Inspect(error) => {
394            if inspect_error_is_non_transient(error) {
395                RetryClass::Slow
396            } else {
397                RetryClass::Transient
398            }
399        }
400        ReconcileError::SqlExec(error) => {
401            if sqlx_error_is_non_transient(error) {
402                RetryClass::Slow
403            } else {
404                RetryClass::Transient
405            }
406        }
407        ReconcileError::Kube(_) => RetryClass::Transient,
408    }
409}
410
411fn inspect_error_is_non_transient(error: &pgroles_inspect::InspectError) -> bool {
412    match error {
413        pgroles_inspect::InspectError::Database(error) => sqlx_error_is_non_transient(error),
414    }
415}
416
417/// Classification of a database-level SQL error for retry and status reporting.
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419enum SqlErrorKind {
420    /// Insufficient privileges (SQLSTATE 42501) — RBAC-style failure,
421    /// won't fix itself.
422    InsufficientPrivileges,
423    /// A referenced schema, relation, function, or object does not exist
424    /// (SQLSTATE 3F000, 42P01, 42883, 42704). Typically a policy/environment
425    /// mismatch that needs operator action.
426    MissingDatabaseObject,
427    /// Everything else — retry with exponential backoff.
428    Transient,
429}
430
431fn classify_sqlx_error(error: &sqlx::Error) -> SqlErrorKind {
432    match error
433        .as_database_error()
434        .and_then(|database_error| database_error.code())
435        .as_deref()
436    {
437        Some(SQLSTATE_INSUFFICIENT_PRIVILEGE) => SqlErrorKind::InsufficientPrivileges,
438        Some(SQLSTATE_INVALID_SCHEMA_NAME)
439        | Some(SQLSTATE_UNDEFINED_TABLE)
440        | Some(SQLSTATE_UNDEFINED_FUNCTION)
441        | Some(SQLSTATE_UNDEFINED_OBJECT) => SqlErrorKind::MissingDatabaseObject,
442        _ => SqlErrorKind::Transient,
443    }
444}
445
446fn sqlx_error_is_non_transient(error: &sqlx::Error) -> bool {
447    !matches!(classify_sqlx_error(error), SqlErrorKind::Transient)
448}
449
450fn next_transient_failure_count(resource: &PostgresPolicy) -> u32 {
451    resource
452        .status
453        .as_ref()
454        .map(|status| status.transient_failure_count.max(0) as u32)
455        .unwrap_or(0)
456        .saturating_add(1)
457}
458
459fn slow_retry_delay(resource: &PostgresPolicy) -> Duration {
460    parse_interval(&resource.spec.interval)
461        .unwrap_or_else(|_| Duration::from_secs(DEFAULT_REQUEUE_SECS))
462}
463
464/// Collect every schema name referenced by an expanded manifest.
465///
466/// Covers schema-type grants (where the schema is in `object.name`), grants on
467/// objects within a schema (where the schema is in `object.schema`), and
468/// default privileges (which always carry a schema).
469fn referenced_schema_names(
470    expanded: &pgroles_core::manifest::ExpandedManifest,
471) -> std::collections::BTreeSet<String> {
472    let mut names: std::collections::BTreeSet<String> = expanded
473        .schemas
474        .iter()
475        .map(|schema| schema.name.clone())
476        .collect();
477    for grant in &expanded.grants {
478        if grant.object.object_type == pgroles_core::manifest::ObjectType::Schema
479            && let Some(name) = &grant.object.name
480        {
481            names.insert(name.clone());
482        }
483        if let Some(schema) = &grant.object.schema {
484            names.insert(schema.clone());
485        }
486    }
487    for dp in &expanded.default_privileges {
488        names.insert(dp.schema.clone());
489    }
490    names
491}
492
493fn declared_schema_names(
494    expanded: &pgroles_core::manifest::ExpandedManifest,
495) -> std::collections::BTreeSet<String> {
496    expanded
497        .schemas
498        .iter()
499        .map(|schema| schema.name.clone())
500        .collect()
501}
502
503/// Pre-flight check: ensure every schema referenced by the policy exists in
504/// the target database. Returns [`ReconcileError::MissingDatabaseObjects`]
505/// listing the missing schemas if any are absent.
506/// Returns true for PostgreSQL system schemas that always exist but are
507/// excluded from [`pgroles_inspect::fetch_existing_schemas`].
508fn is_system_schema(name: &str) -> bool {
509    name.starts_with("pg_") || name == "information_schema"
510}
511
512/// Pre-flight check: ensure every schema referenced by the policy exists in
513/// the target database. Returns [`ReconcileError::MissingDatabaseObjects`]
514/// listing the missing schemas if any are absent.
515///
516/// System schemas (`pg_*`, `information_schema`) are excluded from the check
517/// since they always exist but are filtered out of the inspect query.
518async fn validate_referenced_schemas_exist(
519    pool: &sqlx::PgPool,
520    expanded: &pgroles_core::manifest::ExpandedManifest,
521) -> Result<(), ReconcileError> {
522    let referenced = externally_required_schema_names(expanded);
523    if referenced.is_empty() {
524        return Ok(());
525    }
526    let existing = pgroles_inspect::fetch_existing_schemas(pool).await?;
527    let missing: Vec<String> = referenced
528        .into_iter()
529        .filter(|name| !existing.contains(name))
530        .collect();
531    if missing.is_empty() {
532        Ok(())
533    } else {
534        let formatted = missing
535            .iter()
536            .map(|name| format!("schema \"{name}\""))
537            .collect::<Vec<_>>()
538            .join(", ");
539        Err(ReconcileError::MissingDatabaseObjects(formatted))
540    }
541}
542
543fn externally_required_schema_names(
544    expanded: &pgroles_core::manifest::ExpandedManifest,
545) -> std::collections::BTreeSet<String> {
546    let declared = declared_schema_names(expanded);
547    referenced_schema_names(expanded)
548        .into_iter()
549        .filter(|name| !is_system_schema(name) && !declared.contains(name))
550        .collect()
551}
552
553/// Apply reconciliation — the main "ensure desired state" logic.
554///
555/// The in-process per-database lock is acquired *inside* [`reconcile_apply_inner`],
556/// after the connection probe succeeds. That way a bad-credentials,
557/// secret-fetch, or spec-validation failure produces an ordinary error which
558/// flows through the status-updating path below, instead of competing for
559/// the lock with parallel reconciles for the same `database_identity`. With
560/// three policies sharing one secret, a Secret rotation to bad credentials
561/// would otherwise serialize them on the lock — each holding it for the full
562/// `POOL_ACQUIRE_TIMEOUT_SECS` worth of pool-timeout — and an unlucky policy
563/// could spend tens of seconds in lock-contention requeues without ever
564/// updating its status condition (lock contention is silent by design).
565async fn reconcile_apply(
566    resource: &PostgresPolicy,
567    ctx: &OperatorContext,
568) -> Result<Action, ReconcileError> {
569    let reconcile_guard = ctx.observability.start_reconcile();
570
571    let namespace = resource.namespace().ok_or(ReconcileError::NoNamespace)?;
572    let identity = DatabaseIdentity::from_connection(&namespace, &resource.spec.connection);
573
574    match reconcile_apply_inner(resource, ctx, &identity).await {
575        Ok((action, outcome)) => {
576            reconcile_guard.record_result(outcome.result(), outcome.reason());
577            Ok(action)
578        }
579        Err(ReconcileError::LockContention(db, reason)) => {
580            // Lock contention is expected during normal multi-replica operation.
581            // Re-raise without setting Degraded status to avoid false alarms.
582            ctx.observability.record_lock_contention();
583            reconcile_guard.record_result(
584                ReconcileOutcome::LockContention.result(),
585                ReconcileOutcome::LockContention.reason(),
586            );
587            tracing::info!(database = %db, %reason, "lock contention — will requeue");
588            Err(ReconcileError::LockContention(db, reason))
589        }
590        Err(err) => {
591            let error_message = err.to_string();
592            let error_reason = err.reason();
593            let is_transient_failure =
594                retry_class_for_reconcile_error(&err) == RetryClass::Transient;
595            // Unsatisfiable wildcards would regenerate the same impossible plan.
596            // Other failures keep any plan ref so the same plan can be retried.
597            let clear_current_plan_ref =
598                matches!(&err, ReconcileError::UnsatisfiableWildcardGrant(_));
599            match error_reason {
600                "DatabaseConnectionFailed" => {
601                    ctx.observability.record_database_connection_failure()
602                }
603                "InvalidSpec" => ctx.observability.record_invalid_spec(),
604                "ConflictingPolicy" => ctx.observability.record_policy_conflict(),
605                "ApplyFailed" | "MissingDatabaseObject" | "UnsatisfiableWildcardGrant" => {
606                    ctx.observability.record_apply_result("error")
607                }
608                _ => {}
609            }
610            reconcile_guard.record_result("error", error_reason);
611            if let Err(status_err) = update_status(ctx, resource, |status| {
612                mark_reconcile_failure_status(
613                    status,
614                    error_reason,
615                    &error_message,
616                    is_transient_failure,
617                    clear_current_plan_ref,
618                );
619            })
620            .await
621            {
622                tracing::warn!(%status_err, "failed to update degraded status");
623            }
624            Err(err)
625        }
626    }
627}
628
629fn mark_reconcile_failure_status(
630    status: &mut PostgresPolicyStatus,
631    error_reason: &str,
632    error_message: &str,
633    is_transient_failure: bool,
634    clear_current_plan_ref: bool,
635) {
636    status.set_condition(ready_condition(false, error_reason, error_message));
637    status.set_condition(degraded_condition(error_reason, error_message));
638    status.conditions.retain(|c| {
639        c.condition_type != "Reconciling"
640            && c.condition_type != "Paused"
641            && c.condition_type != "Drifted"
642            && c.condition_type != "Conflict"
643    });
644    status.change_summary = None;
645    status.planned_sql = None;
646    status.planned_sql_truncated = false;
647    if clear_current_plan_ref {
648        status.current_plan_ref = None;
649    }
650    status.last_error = Some(error_message.to_string());
651    if is_transient_failure {
652        status.transient_failure_count += 1;
653    } else {
654        status.transient_failure_count = 0;
655    }
656}
657
658async fn reconcile_apply_inner(
659    resource: &PostgresPolicy,
660    ctx: &OperatorContext,
661    identity: &DatabaseIdentity,
662) -> Result<(Action, ReconcileOutcome), ReconcileError> {
663    let name = resource.name_any();
664    let namespace = resource.namespace().ok_or(ReconcileError::NoNamespace)?;
665
666    let spec = &resource.spec;
667    let requeue_interval = parse_interval(&spec.interval)?;
668    let generation = resource.metadata.generation;
669
670    // If suspended, just requeue without doing anything.
671    if spec.suspend {
672        update_status(ctx, resource, |status| {
673            status.set_condition(paused_condition("Reconciliation suspended by spec"));
674            status.set_condition(ready_condition(
675                false,
676                "Suspended",
677                "Reconciliation suspended by spec",
678            ));
679            status
680                .conditions
681                .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Drifted");
682            status.last_attempted_generation = generation;
683            status.last_error = None;
684            status.planned_sql = None;
685            status.planned_sql_truncated = false;
686            status.transient_failure_count = 0;
687        })
688        .await?;
689        info!(name, namespace, "reconciliation suspended, requeuing");
690        return Ok((
691            Action::requeue(requeue_interval),
692            ReconcileOutcome::Suspended,
693        ));
694    }
695
696    info!(name, namespace, "starting reconciliation");
697
698    // Update status to "Reconciling".
699    // Note: do NOT clear last_error here — it should persist until a successful
700    // reconcile clears it. Clearing on every retry cycle would race with the
701    // error handler that sets it.
702    update_status(ctx, resource, |status| {
703        status.set_condition(reconciling_condition("Reconciliation in progress"));
704        status
705            .conditions
706            .retain(|c| c.condition_type != "Paused" && c.condition_type != "Drifted");
707        status.last_attempted_generation = generation;
708    })
709    .await?;
710
711    spec.validate_connection_spec()
712        .map_err(|err| ReconcileError::InvalidSpec(err.to_string()))?;
713    spec.validate_password_specs(&name)
714        .map_err(|err| ReconcileError::InvalidSpec(err.to_string()))?;
715
716    let ownership = spec.ownership_claims()?;
717    update_status(ctx, resource, |status| {
718        status.managed_database_identity = Some(identity.as_str().to_string());
719        status.owned_roles = ownership.roles.iter().cloned().collect();
720        status.owned_schemas = ownership.schemas.iter().cloned().collect();
721    })
722    .await?;
723
724    if let Some(conflict_message) =
725        detect_policy_conflict(ctx, resource, identity, &ownership).await?
726    {
727        update_status(ctx, resource, |status| {
728            status.set_condition(ready_condition(
729                false,
730                "ConflictingPolicy",
731                &conflict_message,
732            ));
733            status.set_condition(conflict_condition("ConflictingPolicy", &conflict_message));
734            status.set_condition(degraded_condition("ConflictingPolicy", &conflict_message));
735            status
736                .conditions
737                .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Drifted");
738            status.change_summary = None;
739            status.planned_sql = None;
740            status.planned_sql_truncated = false;
741            status.last_error = Some(conflict_message.clone());
742            status.transient_failure_count = 0;
743        })
744        .await?;
745        ctx.observability.record_policy_conflict();
746        info!(name, namespace, %conflict_message, "reconciliation blocked by conflicting policy");
747        return Ok((
748            Action::requeue(requeue_interval),
749            ReconcileOutcome::Conflict,
750        ));
751    }
752
753    // 1. Convert CRD spec to core manifest.
754    let manifest = spec.to_policy_manifest();
755
756    // 2. Expand the manifest (profiles × schemas → concrete roles/grants).
757    let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
758
759    // 3. Build desired RoleGraph from expanded manifest.
760    let default_owner = manifest.default_owner.as_deref();
761    let desired = pgroles_core::model::RoleGraph::from_expanded(&expanded, default_owner)?;
762
763    // 4. Get a database pool.
764    //
765    // This is the connection probe: a bad URL, refused TCP connection, or
766    // failed authentication surfaces here as `ContextError::DatabaseConnect`,
767    // which the outer error handler in `reconcile_apply` translates into a
768    // `Ready=False/DatabaseConnectionFailed` status update. We do this BEFORE
769    // taking the in-process lock so multiple policies sharing the same
770    // `database_identity` can all observe a connection failure in parallel,
771    // instead of serializing on the lock and starving each other under
772    // sustained bad-credentials conditions.
773    let pool = ctx
774        .get_or_create_pool(&namespace, &spec.connection)
775        .await
776        .map_err(Box::new)?;
777
778    // 5. Acquire the in-process per-database lock for the DDL phase.
779    //
780    // The lock serializes inspect+diff+apply against a single
781    // `database_identity` within this replica, so two reconciles can't
782    // compute conflicting plans and stack DDL on top of each other. It is
783    // explicitly NOT held during the connection-probe phase above, since
784    // that work is idempotent and side-effect-free against the database.
785    //
786    // `_db_lock` must outlive the advisory lock and `apply_under_lock`
787    // call below; it is dropped at the end of this function.
788    let _db_lock = match ctx.try_lock_database(identity.as_str()).await {
789        Some(guard) => guard,
790        None => {
791            return Err(ReconcileError::LockContention(
792                identity.as_str().to_string(),
793                "in-process lock held by another reconcile".to_string(),
794            ));
795        }
796    };
797
798    // 6. Acquire PostgreSQL advisory lock for cross-replica safety.
799    let advisory_lock = match crate::advisory::try_acquire(&pool, identity.as_str()).await {
800        Ok(Some(lock)) => lock,
801        Ok(None) => {
802            return Err(ReconcileError::LockContention(
803                identity.as_str().to_string(),
804                "PostgreSQL advisory lock held by another session".to_string(),
805            ));
806        }
807        Err(err) => {
808            tracing::warn!(%err, "failed to acquire advisory lock — treating as connection error");
809            return Err(ReconcileError::SqlExec(err));
810        }
811    };
812
813    // Wrap the remaining work so the advisory lock is released on all paths.
814    let result = apply_under_lock(
815        resource,
816        ctx,
817        &pool,
818        &manifest,
819        &expanded,
820        &desired,
821        generation,
822        requeue_interval,
823        &name,
824        &namespace,
825        identity,
826    )
827    .await;
828
829    // Release advisory lock (always, even on error).
830    advisory_lock.release().await;
831
832    crate::plan::cleanup_old_plans_best_effort(&ctx.kube_client, resource, None).await;
833
834    result
835}
836
837/// Execute the inspect/diff/apply cycle while both locks are held.
838///
839/// Extracted to keep `reconcile_apply_inner` focused on lock acquisition.
840#[allow(clippy::too_many_arguments)]
841async fn apply_under_lock(
842    resource: &PostgresPolicy,
843    ctx: &OperatorContext,
844    pool: &sqlx::PgPool,
845    manifest: &pgroles_core::manifest::PolicyManifest,
846    expanded: &pgroles_core::manifest::ExpandedManifest,
847    desired: &pgroles_core::model::RoleGraph,
848    generation: Option<i64>,
849    requeue_interval: Duration,
850    name: &str,
851    namespace: &str,
852    identity: &DatabaseIdentity,
853) -> Result<(Action, ReconcileOutcome), ReconcileError> {
854    // 5b. Recover stuck Applying plans (operator may have crashed mid-apply).
855    if let Some(stuck_plan) =
856        crate::plan::get_plan_by_phase(&ctx.kube_client, resource, crate::crd::PlanPhase::Applying)
857            .await?
858    {
859        let applying_since_secs = stuck_plan
860            .status
861            .as_ref()
862            .and_then(|s| s.applying_since.as_deref())
863            .and_then(parse_rfc3339_to_epoch_secs);
864        if let Some(since_secs) = applying_since_secs {
865            let now_secs = std::time::SystemTime::now()
866                .duration_since(std::time::UNIX_EPOCH)
867                .unwrap_or_default()
868                .as_secs();
869            let elapsed_secs = now_secs.saturating_sub(since_secs);
870            let stuck_threshold_secs = 5 * 60; // 5 minutes
871            if elapsed_secs > stuck_threshold_secs {
872                tracing::warn!(
873                    plan = %stuck_plan.name_any(),
874                    elapsed_secs,
875                    "detected stuck Applying plan — marking as Failed"
876                );
877                crate::plan::mark_plan_failed(
878                    &ctx.kube_client,
879                    &stuck_plan,
880                    "execution interrupted: operator restarted during apply",
881                )
882                .await?;
883            }
884        }
885    }
886
887    // 6. Inspect current state from the database.
888    let has_database_grants = expanded
889        .grants
890        .iter()
891        .any(|g| g.object.object_type == pgroles_core::manifest::ObjectType::Database);
892    let inspect_config =
893        pgroles_inspect::InspectConfig::from_expanded(expanded, has_database_grants)
894            .with_additional_roles(
895                manifest
896                    .retirements
897                    .iter()
898                    .map(|retirement| retirement.role.clone()),
899            );
900    let inspection = pgroles_inspect::inspect_with_diagnostics(pool, &inspect_config).await?;
901    ctx.observability.record_inspection(&inspection.stats);
902    if !inspection.diagnostics.is_empty() {
903        return Err(ReconcileError::UnsatisfiableWildcardGrant(
904            inspection.diagnostics.to_string(),
905        ));
906    }
907    let current = inspection.graph;
908
909    // 6b. Pre-flight: validate that every schema referenced by the policy
910    // exists in the target database. This turns a mid-transaction
911    // `schema "X" does not exist` failure into a clear spec/environment
912    // mismatch error before we issue any DDL.
913    validate_referenced_schemas_exist(pool, expanded).await?;
914
915    // 7. Compute diff, filter by reconciliation mode, then inject password
916    // changes resolved from Kubernetes Secrets.
917    let reconciliation_mode: pgroles_core::diff::ReconciliationMode =
918        resource.spec.reconciliation_mode.into();
919    tracing::info!(%reconciliation_mode, "reconciliation mode");
920    let mut changes = pgroles_core::diff::filter_changes(
921        pgroles_core::diff::apply_role_retirements(
922            pgroles_core::diff::diff(&current, desired),
923            &manifest.retirements,
924        ),
925        reconciliation_mode,
926    );
927
928    let resolved_passwords = resolve_passwords_from_secrets(ctx, resource, namespace).await?;
929    let (password_changes, applied_password_source_versions) =
930        select_password_changes(&changes, &resolved_passwords, resource.status.as_ref());
931    if !password_changes.is_empty() {
932        changes = pgroles_core::diff::inject_password_changes(changes, &password_changes);
933    }
934    let dropped_roles: Vec<String> = changes
935        .iter()
936        .filter_map(|change| match change {
937            pgroles_core::diff::Change::DropRole { name } => Some(name.clone()),
938            _ => None,
939        })
940        .collect();
941    let drop_safety = pgroles_inspect::inspect_drop_role_safety(pool, &dropped_roles)
942        .await?
943        .assess(&manifest.retirements);
944    if !drop_safety.warnings.is_empty() {
945        tracing::info!(warnings = %drop_safety.warnings, "role-drop cleanup warnings");
946    }
947    if drop_safety.has_blockers() {
948        return Err(ReconcileError::UnsafeRoleDrops(
949            drop_safety.blockers.to_string(),
950        ));
951    }
952
953    let summary = summarize_changes(&changes);
954    let sql_ctx = detect_sql_context(pool, &inspect_config).await?;
955    let (planned_sql, planned_sql_truncated) = render_plan_sql_for_status(&changes, &sql_ctx);
956
957    let effective_approval = resource.spec.effective_approval();
958
959    if resource.spec.mode == PolicyMode::Plan {
960        let drift_detected = !changes.is_empty();
961        let ready_message = if drift_detected {
962            format!("Plan computed; {} change(s) pending", summary.total)
963        } else {
964            "Plan computed; database already matches desired state".to_string()
965        };
966        let drift_reason = if drift_detected {
967            "DriftDetected"
968        } else {
969            "InSync"
970        };
971        let drift_message = if drift_detected {
972            format!("{} planned change(s) pending review", summary.total)
973        } else {
974            "No pending changes".to_string()
975        };
976
977        ctx.observability
978            .record_plan_result(if drift_detected { "drift" } else { "clean" });
979        ctx.observability
980            .record_planned_changes(summary.total.max(0) as usize);
981
982        // Create a PostgresPolicyPlan resource for changes (if any).
983        let mut plan_ref_name = None;
984        if drift_detected {
985            let creation_result = crate::plan::create_or_update_plan(
986                &ctx.kube_client,
987                resource,
988                &changes,
989                &sql_ctx,
990                &inspect_config,
991                resource.spec.reconciliation_mode,
992                identity.as_str(),
993                &summary,
994            )
995            .await?;
996            let plan_name = creation_result.plan_name().to_string();
997
998            // Only emit PlanCreated event for genuinely new plans, not dedup hits.
999            if creation_result.is_created() {
1000                let plans_api: Api<PostgresPolicyPlan> =
1001                    Api::namespaced(ctx.kube_client.clone(), namespace);
1002                let created_plan = plans_api.get(&plan_name).await?;
1003                emit_plan_event(
1004                    ctx,
1005                    resource,
1006                    &created_plan,
1007                    PlanEventType::Created {
1008                        change_count: summary.total,
1009                    },
1010                )
1011                .await;
1012            }
1013
1014            crate::plan::update_policy_plan_ref(&ctx.kube_client, resource, &plan_name).await?;
1015
1016            plan_ref_name = Some(plan_name);
1017        }
1018
1019        // Still write deprecated planned_sql to status for backward compat.
1020        update_status(ctx, resource, |status| {
1021            status.set_condition(ready_condition(true, "Planned", &ready_message));
1022            status.set_condition(drifted_condition(
1023                drift_detected,
1024                drift_reason,
1025                &drift_message,
1026            ));
1027            status.conditions.retain(|c| {
1028                c.condition_type != "Reconciling"
1029                    && c.condition_type != "Degraded"
1030                    && c.condition_type != "Conflict"
1031                    && c.condition_type != "Paused"
1032            });
1033            status.observed_generation = generation;
1034            status.last_attempted_generation = generation;
1035            status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1036            status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1037            status.change_summary = Some(summary.clone());
1038            status.last_reconcile_mode = Some(PolicyMode::Plan);
1039            status.planned_sql = planned_sql.clone();
1040            status.planned_sql_truncated = planned_sql_truncated;
1041            status.last_error = None;
1042            status.transient_failure_count = 0;
1043            if let Some(ref plan_name) = plan_ref_name {
1044                status.current_plan_ref = Some(crate::crd::PlanReference {
1045                    name: plan_name.clone(),
1046                });
1047            }
1048        })
1049        .await?;
1050
1051        info!(
1052            name,
1053            namespace,
1054            total = summary.total,
1055            drift_detected,
1056            "plan reconciliation complete"
1057        );
1058        return Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned));
1059    }
1060
1061    // Apply mode — behavior depends on effective approval mode.
1062    match effective_approval {
1063        crate::crd::ApprovalMode::Auto => {
1064            // Auto-approval: create plan -> immediately execute -> update status.
1065            // This wraps the existing apply behavior in the plan lifecycle.
1066            if !changes.is_empty() {
1067                let creation_result = crate::plan::create_or_update_plan(
1068                    &ctx.kube_client,
1069                    resource,
1070                    &changes,
1071                    &sql_ctx,
1072                    &inspect_config,
1073                    resource.spec.reconciliation_mode,
1074                    identity.as_str(),
1075                    &summary,
1076                )
1077                .await?;
1078                let plan_name = creation_result.plan_name().to_string();
1079
1080                // Fetch the plan, mark it approved, and execute it.
1081                let plans_api: Api<PostgresPolicyPlan> =
1082                    Api::namespaced(ctx.kube_client.clone(), namespace);
1083                let plan = plans_api.get(&plan_name).await?;
1084
1085                if creation_result.is_created() {
1086                    emit_plan_event(
1087                        ctx,
1088                        resource,
1089                        &plan,
1090                        PlanEventType::Created {
1091                            change_count: summary.total,
1092                        },
1093                    )
1094                    .await;
1095                }
1096
1097                crate::plan::mark_plan_approved(
1098                    &ctx.kube_client,
1099                    &plan,
1100                    "AutoApproved",
1101                    "Plan auto-approved by policy approval mode",
1102                )
1103                .await?;
1104
1105                // Re-fetch after approval status update.
1106                let plan = plans_api.get(&plan_name).await?;
1107                emit_plan_event(ctx, resource, &plan, PlanEventType::Approved).await;
1108                emit_plan_event(ctx, resource, &plan, PlanEventType::ApplyStarted).await;
1109
1110                match crate::plan::execute_plan(&ctx.kube_client, &plan, pool, &sql_ctx, &changes)
1111                    .await
1112                {
1113                    Ok(()) => {
1114                        emit_plan_event(ctx, resource, &plan, PlanEventType::ApplySucceeded).await;
1115                    }
1116                    Err(err) => {
1117                        emit_plan_event(
1118                            ctx,
1119                            resource,
1120                            &plan,
1121                            PlanEventType::ApplyFailed {
1122                                error: err.to_string(),
1123                            },
1124                        )
1125                        .await;
1126                        return Err(err);
1127                    }
1128                }
1129
1130                ctx.observability.record_apply_result("success");
1131
1132                crate::plan::update_policy_plan_ref(&ctx.kube_client, resource, &plan_name).await?;
1133
1134                info!(
1135                    name,
1136                    namespace,
1137                    total = summary.total,
1138                    plan = %plan_name,
1139                    "auto-approved plan applied"
1140                );
1141            } else {
1142                info!(name, namespace, "no changes needed");
1143            }
1144
1145            // Update status to Ready.
1146            update_status(ctx, resource, |status| {
1147                status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1148                status.set_condition(drifted_condition(false, "InSync", "No pending changes"));
1149                status.conditions.retain(|c| {
1150                    c.condition_type != "Reconciling"
1151                        && c.condition_type != "Degraded"
1152                        && c.condition_type != "Conflict"
1153                        && c.condition_type != "Paused"
1154                });
1155                status.observed_generation = generation;
1156                status.last_attempted_generation = generation;
1157                status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1158                status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1159                status.change_summary = Some(summary);
1160                status.last_reconcile_mode = Some(PolicyMode::Apply);
1161                status.planned_sql = None;
1162                status.planned_sql_truncated = false;
1163                status.last_error = None;
1164                status.applied_password_source_versions = applied_password_source_versions;
1165                status.transient_failure_count = 0;
1166            })
1167            .await?;
1168
1169            Ok((
1170                Action::requeue(requeue_interval),
1171                ReconcileOutcome::Reconciled,
1172            ))
1173        }
1174        crate::crd::ApprovalMode::Manual => {
1175            // Manual approval: check for an existing approved plan, or create one.
1176
1177            // First, check if there is a current pending plan that has been approved.
1178            if let Some(current_plan) =
1179                crate::plan::get_current_actionable_plan(&ctx.kube_client, resource).await?
1180            {
1181                let approval_state = crate::plan::check_plan_approval(&current_plan);
1182
1183                match approval_state {
1184                    crate::plan::PlanApprovalState::Approved => {
1185                        // Validate that the database state has not drifted since
1186                        // the plan was approved by comparing SQL hashes.
1187                        let fresh_sql = crate::plan::render_full_sql(&changes, &sql_ctx);
1188                        let fresh_hash = crate::plan::compute_sql_hash(&fresh_sql);
1189                        let stored_hash = current_plan
1190                            .status
1191                            .as_ref()
1192                            .and_then(|s| s.sql_hash.as_deref());
1193
1194                        if stored_hash != Some(&fresh_hash) {
1195                            // Database state changed since the plan was approved.
1196                            tracing::warn!(
1197                                plan = %current_plan.name_any(),
1198                                stored_hash = ?stored_hash,
1199                                fresh_hash = %fresh_hash,
1200                                "approved plan superseded: database state changed since approval"
1201                            );
1202
1203                            crate::plan::mark_plan_superseded(&ctx.kube_client, &current_plan)
1204                                .await?;
1205
1206                            // Create a new plan with the fresh changes.
1207                            let new_creation_result = crate::plan::create_or_update_plan(
1208                                &ctx.kube_client,
1209                                resource,
1210                                &changes,
1211                                &sql_ctx,
1212                                &inspect_config,
1213                                resource.spec.reconciliation_mode,
1214                                identity.as_str(),
1215                                &summary,
1216                            )
1217                            .await?;
1218                            let new_plan_name = new_creation_result.plan_name().to_string();
1219
1220                            if new_creation_result.is_created() {
1221                                let plans_api: Api<PostgresPolicyPlan> =
1222                                    Api::namespaced(ctx.kube_client.clone(), namespace);
1223                                let new_plan = plans_api.get(&new_plan_name).await?;
1224                                emit_plan_event(
1225                                    ctx,
1226                                    resource,
1227                                    &new_plan,
1228                                    PlanEventType::Created {
1229                                        change_count: summary.total,
1230                                    },
1231                                )
1232                                .await;
1233                            }
1234
1235                            crate::plan::update_policy_plan_ref(
1236                                &ctx.kube_client,
1237                                resource,
1238                                &new_plan_name,
1239                            )
1240                            .await?;
1241
1242                            let msg = format!(
1243                                "Plan {} superseded (DB state changed); new plan {} created with {} change(s) awaiting approval",
1244                                current_plan.name_any(),
1245                                new_plan_name,
1246                                summary.total,
1247                            );
1248                            update_status(ctx, resource, |status| {
1249                                status.set_condition(ready_condition(true, "Planned", &msg));
1250                                status.set_condition(drifted_condition(
1251                                    true,
1252                                    "DriftDetected",
1253                                    &format!("{} planned change(s) pending review", summary.total),
1254                                ));
1255                                status.conditions.retain(|c| {
1256                                    c.condition_type != "Reconciling"
1257                                        && c.condition_type != "Degraded"
1258                                        && c.condition_type != "Conflict"
1259                                        && c.condition_type != "Paused"
1260                                });
1261                                status.last_attempted_generation = generation;
1262                                status.change_summary = Some(summary.clone());
1263                                status.last_reconcile_mode = Some(PolicyMode::Apply);
1264                                status.planned_sql = planned_sql.clone();
1265                                status.planned_sql_truncated = planned_sql_truncated;
1266                                status.last_error = None;
1267                                status.transient_failure_count = 0;
1268                                status.current_plan_ref = Some(crate::crd::PlanReference {
1269                                    name: new_plan_name.clone(),
1270                                });
1271                            })
1272                            .await?;
1273
1274                            return Ok((
1275                                Action::requeue(requeue_interval),
1276                                ReconcileOutcome::Planned,
1277                            ));
1278                        }
1279
1280                        // Hash matches — safe to execute the approved plan.
1281                        info!(
1282                            name,
1283                            namespace,
1284                            plan = %current_plan.name_any(),
1285                            "executing manually approved plan"
1286                        );
1287
1288                        emit_plan_event(ctx, resource, &current_plan, PlanEventType::Approved)
1289                            .await;
1290
1291                        crate::plan::mark_plan_approved(
1292                            &ctx.kube_client,
1293                            &current_plan,
1294                            "ManuallyApproved",
1295                            "Plan approved via annotation",
1296                        )
1297                        .await?;
1298
1299                        let plans_api: Api<PostgresPolicyPlan> =
1300                            Api::namespaced(ctx.kube_client.clone(), namespace);
1301                        let plan = plans_api.get(&current_plan.name_any()).await?;
1302
1303                        emit_plan_event(ctx, resource, &plan, PlanEventType::ApplyStarted).await;
1304
1305                        match crate::plan::execute_plan(
1306                            &ctx.kube_client,
1307                            &plan,
1308                            pool,
1309                            &sql_ctx,
1310                            &changes,
1311                        )
1312                        .await
1313                        {
1314                            Ok(()) => {
1315                                emit_plan_event(
1316                                    ctx,
1317                                    resource,
1318                                    &plan,
1319                                    PlanEventType::ApplySucceeded,
1320                                )
1321                                .await;
1322                            }
1323                            Err(err) => {
1324                                emit_plan_event(
1325                                    ctx,
1326                                    resource,
1327                                    &plan,
1328                                    PlanEventType::ApplyFailed {
1329                                        error: err.to_string(),
1330                                    },
1331                                )
1332                                .await;
1333                                return Err(err);
1334                            }
1335                        }
1336
1337                        ctx.observability.record_apply_result("success");
1338
1339                        // Update status to Ready.
1340                        update_status(ctx, resource, |status| {
1341                            status.set_condition(ready_condition(
1342                                true,
1343                                "Reconciled",
1344                                "Approved plan applied",
1345                            ));
1346                            status.set_condition(drifted_condition(
1347                                false,
1348                                "InSync",
1349                                "No pending changes",
1350                            ));
1351                            status.conditions.retain(|c| {
1352                                c.condition_type != "Reconciling"
1353                                    && c.condition_type != "Degraded"
1354                                    && c.condition_type != "Conflict"
1355                                    && c.condition_type != "Paused"
1356                            });
1357                            status.observed_generation = generation;
1358                            status.last_attempted_generation = generation;
1359                            status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1360                            status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1361                            status.change_summary = Some(summary);
1362                            status.last_reconcile_mode = Some(PolicyMode::Apply);
1363                            status.planned_sql = None;
1364                            status.planned_sql_truncated = false;
1365                            status.last_error = None;
1366                            status.applied_password_source_versions =
1367                                applied_password_source_versions;
1368                            status.transient_failure_count = 0;
1369                        })
1370                        .await?;
1371
1372                        return Ok((
1373                            Action::requeue(requeue_interval),
1374                            ReconcileOutcome::Reconciled,
1375                        ));
1376                    }
1377                    crate::plan::PlanApprovalState::Rejected => {
1378                        crate::plan::mark_plan_rejected(&ctx.kube_client, &current_plan).await?;
1379                        emit_plan_event(ctx, resource, &current_plan, PlanEventType::Rejected)
1380                            .await;
1381                        info!(
1382                            name,
1383                            namespace,
1384                            plan = %current_plan.name_any(),
1385                            "plan rejected via annotation"
1386                        );
1387
1388                        // Update status to reflect rejection, but don't create a new plan
1389                        // in the same cycle to avoid tight reject-create loops.
1390                        update_status(ctx, resource, |status| {
1391                            status.set_condition(ready_condition(
1392                                true,
1393                                "Planned",
1394                                &format!(
1395                                    "Plan {} rejected; new plan will be created on next reconcile",
1396                                    current_plan.name_any()
1397                                ),
1398                            ));
1399                            status.last_attempted_generation = generation;
1400                            status.last_error = None;
1401                            status.transient_failure_count = 0;
1402                            status.current_plan_ref = None;
1403                        })
1404                        .await?;
1405
1406                        return Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned));
1407                    }
1408                    crate::plan::PlanApprovalState::Pending => {
1409                        // Plan exists and is pending — nothing to do, requeue.
1410                        info!(
1411                            name,
1412                            namespace,
1413                            plan = %current_plan.name_any(),
1414                            "plan awaiting manual approval"
1415                        );
1416
1417                        update_status(ctx, resource, |status| {
1418                            let msg = format!(
1419                                "Plan {} awaiting approval; {} change(s) pending",
1420                                current_plan.name_any(),
1421                                summary.total,
1422                            );
1423                            status.set_condition(ready_condition(true, "Planned", &msg));
1424                            status.set_condition(drifted_condition(
1425                                !changes.is_empty(),
1426                                if changes.is_empty() {
1427                                    "InSync"
1428                                } else {
1429                                    "DriftDetected"
1430                                },
1431                                &msg,
1432                            ));
1433                            status.conditions.retain(|c| {
1434                                c.condition_type != "Reconciling"
1435                                    && c.condition_type != "Degraded"
1436                                    && c.condition_type != "Conflict"
1437                                    && c.condition_type != "Paused"
1438                            });
1439                            status.last_attempted_generation = generation;
1440                            status.change_summary = Some(summary.clone());
1441                            status.planned_sql = planned_sql.clone();
1442                            status.planned_sql_truncated = planned_sql_truncated;
1443                            status.last_error = None;
1444                            status.transient_failure_count = 0;
1445                        })
1446                        .await?;
1447
1448                        return Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned));
1449                    }
1450                }
1451            }
1452
1453            // No pending plan (or previous one was rejected) — create a new plan.
1454            if changes.is_empty() {
1455                info!(name, namespace, "no changes needed (manual approval mode)");
1456
1457                update_status(ctx, resource, |status| {
1458                    status.set_condition(ready_condition(true, "Reconciled", "No changes needed"));
1459                    status.set_condition(drifted_condition(false, "InSync", "No pending changes"));
1460                    status.conditions.retain(|c| {
1461                        c.condition_type != "Reconciling"
1462                            && c.condition_type != "Degraded"
1463                            && c.condition_type != "Conflict"
1464                            && c.condition_type != "Paused"
1465                    });
1466                    status.observed_generation = generation;
1467                    status.last_attempted_generation = generation;
1468                    status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1469                    status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1470                    status.change_summary = Some(summary);
1471                    status.last_reconcile_mode = Some(PolicyMode::Apply);
1472                    status.planned_sql = None;
1473                    status.planned_sql_truncated = false;
1474                    status.last_error = None;
1475                    status.applied_password_source_versions = applied_password_source_versions;
1476                    status.transient_failure_count = 0;
1477                })
1478                .await?;
1479
1480                return Ok((
1481                    Action::requeue(requeue_interval),
1482                    ReconcileOutcome::Reconciled,
1483                ));
1484            }
1485
1486            // Create a new plan and wait for approval.
1487            let creation_result = crate::plan::create_or_update_plan(
1488                &ctx.kube_client,
1489                resource,
1490                &changes,
1491                &sql_ctx,
1492                &inspect_config,
1493                resource.spec.reconciliation_mode,
1494                identity.as_str(),
1495                &summary,
1496            )
1497            .await?;
1498            let plan_name = creation_result.plan_name().to_string();
1499
1500            // Only emit PlanCreated event for genuinely new plans, not dedup hits.
1501            if creation_result.is_created() {
1502                let plans_api: Api<PostgresPolicyPlan> =
1503                    Api::namespaced(ctx.kube_client.clone(), namespace);
1504                let created_plan = plans_api.get(&plan_name).await?;
1505                emit_plan_event(
1506                    ctx,
1507                    resource,
1508                    &created_plan,
1509                    PlanEventType::Created {
1510                        change_count: summary.total,
1511                    },
1512                )
1513                .await;
1514            }
1515
1516            crate::plan::update_policy_plan_ref(&ctx.kube_client, resource, &plan_name).await?;
1517
1518            let msg = format!(
1519                "Plan {plan_name} created; {} change(s) awaiting approval",
1520                summary.total,
1521            );
1522            update_status(ctx, resource, |status| {
1523                status.set_condition(ready_condition(true, "Planned", &msg));
1524                status.set_condition(drifted_condition(
1525                    true,
1526                    "DriftDetected",
1527                    &format!("{} planned change(s) pending review", summary.total),
1528                ));
1529                status.conditions.retain(|c| {
1530                    c.condition_type != "Reconciling"
1531                        && c.condition_type != "Degraded"
1532                        && c.condition_type != "Conflict"
1533                        && c.condition_type != "Paused"
1534                });
1535                status.last_attempted_generation = generation;
1536                status.change_summary = Some(summary.clone());
1537                status.last_reconcile_mode = Some(PolicyMode::Apply);
1538                status.planned_sql = planned_sql.clone();
1539                status.planned_sql_truncated = planned_sql_truncated;
1540                status.last_error = None;
1541                status.transient_failure_count = 0;
1542                status.current_plan_ref = Some(crate::crd::PlanReference {
1543                    name: plan_name.clone(),
1544                });
1545            })
1546            .await?;
1547
1548            info!(
1549                name,
1550                namespace,
1551                total = summary.total,
1552                plan = %plan_name,
1553                "plan created, awaiting manual approval"
1554            );
1555
1556            Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned))
1557        }
1558    }
1559}
1560
1561/// Resolve role passwords from Kubernetes Secrets or generate them.
1562///
1563/// For each role that declares a `password`:
1564/// - `PasswordSpec::SecretRef`: fetches the password from the referenced Secret.
1565/// - `PasswordSpec::Generate`: reads the generated Secret if it exists; in
1566///   apply mode it creates the Secret if needed, while in plan mode it keeps
1567///   reconciliation non-mutating and synthesizes an in-memory password.
1568///
1569/// Returns a map of role name → cleartext password string suitable for
1570/// [`pgroles_core::diff::inject_password_changes`] (which computes the
1571/// SCRAM-SHA-256 verifier before creating `SetPassword` changes).
1572async fn resolve_passwords_from_secrets(
1573    ctx: &OperatorContext,
1574    resource: &PostgresPolicy,
1575    namespace: &str,
1576) -> Result<std::collections::BTreeMap<String, ResolvedPassword>, ReconcileError> {
1577    use k8s_openapi::api::core::v1::Secret;
1578
1579    let mut resolved = std::collections::BTreeMap::new();
1580
1581    // Cache fetched Secrets by name to avoid duplicate API calls when
1582    // multiple roles reference different keys in the same Secret.
1583    let mut secret_cache: std::collections::BTreeMap<String, Secret> =
1584        std::collections::BTreeMap::new();
1585
1586    let secrets_api: kube::Api<Secret> = kube::Api::namespaced(ctx.kube_client.clone(), namespace);
1587
1588    // First pass: fetch all referenced Secrets for secretRef roles.
1589    for role_spec in &resource.spec.roles {
1590        if let Some(pw) = &role_spec.password
1591            && let Some(secret_ref) = &pw.secret_ref
1592        {
1593            let secret_name = &secret_ref.name;
1594            if !secret_cache.contains_key(secret_name.as_str()) {
1595                let fetched = secrets_api.get(secret_name).await.map_err(|err| {
1596                    Box::new(crate::context::ContextError::SecretFetch {
1597                        name: secret_name.clone(),
1598                        namespace: namespace.to_string(),
1599                        source: err,
1600                    })
1601                })?;
1602                secret_cache.insert(secret_name.clone(), fetched);
1603            }
1604        }
1605    }
1606
1607    // Second pass: resolve passwords from cache (secretRef) or generate.
1608    for role_spec in &resource.spec.roles {
1609        if let Some(pw) = &role_spec.password {
1610            if let Some(gen_spec) = &pw.generate {
1611                let password = if resource.spec.mode == PolicyMode::Plan {
1612                    match crate::password::get_generated_secret(
1613                        ctx.kube_client.clone(),
1614                        namespace,
1615                        &resource.name_any(),
1616                        &role_spec.name,
1617                        gen_spec,
1618                    )
1619                    .await
1620                    .map_err(Box::new)?
1621                    {
1622                        Some(existing) => existing,
1623                        None => {
1624                            let secret_name = crate::password::generated_secret_name(
1625                                &resource.name_any(),
1626                                &role_spec.name,
1627                                gen_spec,
1628                            );
1629                            let secret_key = crate::password::generated_secret_key(gen_spec);
1630                            let cleartext = crate::password::generate_password(
1631                                gen_spec
1632                                    .length
1633                                    .unwrap_or(crate::password::DEFAULT_PASSWORD_LENGTH),
1634                            );
1635
1636                            crate::password::GeneratedPasswordSecret {
1637                                password: cleartext,
1638                                source_version:
1639                                    crate::password::missing_generated_secret_source_version(
1640                                        &secret_name,
1641                                        &secret_key,
1642                                    ),
1643                            }
1644                        }
1645                    }
1646                } else {
1647                    // Apply mode — ensure a Secret exists with a generated password.
1648                    crate::password::ensure_generated_secret(
1649                        ctx.kube_client.clone(),
1650                        namespace,
1651                        resource,
1652                        &role_spec.name,
1653                        gen_spec,
1654                    )
1655                    .await
1656                    .map_err(Box::new)?
1657                };
1658                resolved.insert(
1659                    role_spec.name.clone(),
1660                    ResolvedPassword {
1661                        cleartext: password.password,
1662                        source_version: password.source_version,
1663                    },
1664                );
1665            } else if pw.secret_ref.is_some() {
1666                // SecretRef mode — read from an existing Secret.
1667                let password = resolve_password_from_cache(&role_spec.name, pw, &secret_cache)?;
1668                resolved.insert(role_spec.name.clone(), password);
1669            }
1670        }
1671    }
1672
1673    Ok(resolved)
1674}
1675
1676/// Extract a password from a pre-fetched Secret cache for a `secretRef` role.
1677fn resolve_password_from_cache(
1678    role_name: &str,
1679    password_spec: &crate::crd::PasswordSpec,
1680    secret_cache: &std::collections::BTreeMap<String, k8s_openapi::api::core::v1::Secret>,
1681) -> Result<ResolvedPassword, ReconcileError> {
1682    let secret_ref = password_spec.secret_ref.as_ref().ok_or_else(|| {
1683        Box::new(crate::context::ContextError::SecretMissing {
1684            name: "(no secretRef)".to_string(),
1685            key: role_name.to_string(),
1686        })
1687    })?;
1688    let secret_name = &secret_ref.name;
1689    let secret_key = password_spec.secret_key.as_deref().unwrap_or(role_name);
1690
1691    let secret = secret_cache.get(secret_name.as_str()).ok_or_else(|| {
1692        Box::new(crate::context::ContextError::SecretMissing {
1693            name: secret_name.clone(),
1694            key: secret_key.to_string(),
1695        })
1696    })?;
1697
1698    let data = secret.data.as_ref().ok_or_else(|| {
1699        Box::new(crate::context::ContextError::SecretMissing {
1700            name: secret_name.clone(),
1701            key: secret_key.to_string(),
1702        })
1703    })?;
1704
1705    let value_bytes = data.get(secret_key).ok_or_else(|| {
1706        Box::new(crate::context::ContextError::SecretMissing {
1707            name: secret_name.clone(),
1708            key: secret_key.to_string(),
1709        })
1710    })?;
1711
1712    let password = String::from_utf8(value_bytes.0.clone()).map_err(|_| {
1713        Box::new(crate::context::ContextError::SecretMissing {
1714            name: secret_name.clone(),
1715            key: secret_key.to_string(),
1716        })
1717    })?;
1718
1719    if password.is_empty() {
1720        return Err(ReconcileError::EmptyPasswordSecret {
1721            role: role_name.to_string(),
1722            secret: secret_name.clone(),
1723            key: secret_key.to_string(),
1724        });
1725    }
1726
1727    let resource_version = secret
1728        .metadata
1729        .resource_version
1730        .as_deref()
1731        .unwrap_or("unknown");
1732    Ok(ResolvedPassword {
1733        cleartext: password,
1734        source_version: format!("{secret_name}:{secret_key}:{resource_version}"),
1735    })
1736}
1737
1738/// Resolve passwords from a pre-populated cache (for unit testing without K8s).
1739#[cfg(test)]
1740fn resolve_passwords_from_cached_secrets(
1741    resource: &PostgresPolicy,
1742    secret_cache: &std::collections::BTreeMap<String, k8s_openapi::api::core::v1::Secret>,
1743) -> Result<std::collections::BTreeMap<String, ResolvedPassword>, ReconcileError> {
1744    let mut resolved = std::collections::BTreeMap::new();
1745    for role_spec in &resource.spec.roles {
1746        if let Some(pw) = &role_spec.password
1747            && pw.secret_ref.is_some()
1748        {
1749            let password = resolve_password_from_cache(&role_spec.name, pw, secret_cache)?;
1750            resolved.insert(role_spec.name.clone(), password);
1751        }
1752    }
1753    Ok(resolved)
1754}
1755
1756fn select_password_changes(
1757    changes: &[pgroles_core::diff::Change],
1758    resolved_passwords: &std::collections::BTreeMap<String, ResolvedPassword>,
1759    status: Option<&PostgresPolicyStatus>,
1760) -> (
1761    std::collections::BTreeMap<String, String>,
1762    std::collections::BTreeMap<String, String>,
1763) {
1764    let created_roles: std::collections::BTreeSet<&str> = changes
1765        .iter()
1766        .filter_map(|change| match change {
1767            pgroles_core::diff::Change::CreateRole { name, .. } => Some(name.as_str()),
1768            _ => None,
1769        })
1770        .collect();
1771    let previous_versions = status
1772        .map(|status| &status.applied_password_source_versions)
1773        .cloned()
1774        .unwrap_or_default();
1775
1776    let mut password_changes = std::collections::BTreeMap::new();
1777    let mut current_versions = std::collections::BTreeMap::new();
1778
1779    for (role, resolved) in resolved_passwords {
1780        current_versions.insert(role.clone(), resolved.source_version.clone());
1781        if created_roles.contains(role.as_str())
1782            || previous_versions.get(role) != Some(&resolved.source_version)
1783        {
1784            password_changes.insert(role.clone(), resolved.cleartext.clone());
1785        }
1786    }
1787
1788    (password_changes, current_versions)
1789}
1790
1791/// Cleanup on deletion — evict cached pool.
1792async fn reconcile_cleanup(
1793    resource: &PostgresPolicy,
1794    ctx: &OperatorContext,
1795) -> Result<Action, ReconcileError> {
1796    let name = resource.name_any();
1797    let namespace = resource.namespace().ok_or(ReconcileError::NoNamespace)?;
1798
1799    info!(name, namespace, "cleaning up (resource deleted)");
1800
1801    // Evict any cached pool for this resource's connection.
1802    ctx.evict_pool(&namespace, &resource.spec.connection).await;
1803
1804    // Note: we do NOT revoke grants on deletion. The resource being deleted
1805    // means the user no longer wants pgroles to manage these roles — it does
1806    // NOT mean "revoke everything". This is the safe default.
1807
1808    Ok(Action::await_change())
1809}
1810
1811/// Accumulate change counts into the summary.
1812fn accumulate_summary(summary: &mut ChangeSummary, change: &pgroles_core::diff::Change) {
1813    use pgroles_core::diff::Change;
1814    match change {
1815        Change::CreateRole { .. } => summary.roles_created += 1,
1816        Change::CreateSchema { .. } => summary.schemas_created += 1,
1817        Change::AlterSchemaOwner { .. } => summary.schema_owners_altered += 1,
1818        Change::AlterRole { .. } => summary.roles_altered += 1,
1819        Change::SetComment { .. } => summary.roles_altered += 1,
1820        Change::DropRole { .. } => summary.roles_dropped += 1,
1821        Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
1822        Change::ReassignOwned { .. } => {}
1823        Change::DropOwned { .. } => {}
1824        Change::Grant { .. } | Change::EnsureSchemaOwnerPrivileges { .. } => {
1825            summary.grants_added += 1
1826        }
1827        Change::Revoke { .. } => summary.grants_revoked += 1,
1828        Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
1829        Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
1830        Change::AddMember { .. } => summary.members_added += 1,
1831        Change::RemoveMember { .. } => summary.members_removed += 1,
1832        Change::SetPassword { .. } => summary.passwords_set += 1,
1833    }
1834}
1835
1836fn summarize_changes(changes: &[pgroles_core::diff::Change]) -> ChangeSummary {
1837    let mut summary = ChangeSummary::default();
1838    for change in changes {
1839        accumulate_summary(&mut summary, change);
1840    }
1841    summary.total = summary.roles_created
1842        + summary.roles_altered
1843        + summary.schemas_created
1844        + summary.schema_owners_altered
1845        + summary.roles_dropped
1846        + summary.sessions_terminated
1847        + summary.grants_added
1848        + summary.grants_revoked
1849        + summary.default_privileges_set
1850        + summary.default_privileges_revoked
1851        + summary.members_added
1852        + summary.members_removed
1853        + summary.passwords_set;
1854    summary
1855}
1856
1857/// Parse a simplified RFC 3339 / ISO 8601 timestamp (`YYYY-MM-DDTHH:MM:SSZ`)
1858/// into seconds since the Unix epoch.
1859///
1860/// Returns `None` if the string does not match the expected format.
1861fn parse_rfc3339_to_epoch_secs(timestamp: &str) -> Option<u64> {
1862    // Expected format: "2026-03-31T12:34:56Z"
1863    if timestamp.len() < 20 || !timestamp.ends_with('Z') {
1864        return None;
1865    }
1866    let year: u64 = timestamp.get(0..4)?.parse().ok()?;
1867    let month: u64 = timestamp.get(5..7)?.parse().ok()?;
1868    let day: u64 = timestamp.get(8..10)?.parse().ok()?;
1869    let hours: u64 = timestamp.get(11..13)?.parse().ok()?;
1870    let minutes: u64 = timestamp.get(14..16)?.parse().ok()?;
1871    let seconds: u64 = timestamp.get(17..19)?.parse().ok()?;
1872
1873    // Convert to days since epoch using the inverse of the civil algorithm.
1874    let (y, m) = if month <= 2 {
1875        (year - 1, month + 9)
1876    } else {
1877        (year, month - 3)
1878    };
1879    let era = y / 400;
1880    let yoe = y - era * 400;
1881    let doy = (153 * m + 2) / 5 + day - 1;
1882    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1883    let days_since_epoch = era * 146097 + doe - 719468;
1884
1885    Some(days_since_epoch * 86400 + hours * 3600 + minutes * 60 + seconds)
1886}
1887
1888async fn detect_sql_context(
1889    pool: &sqlx::PgPool,
1890    inspect_config: &pgroles_inspect::InspectConfig,
1891) -> Result<pgroles_core::sql::SqlContext, ReconcileError> {
1892    let pg_version = pgroles_inspect::detect_pg_version(pool).await?;
1893    let privilege_schemas: Vec<&str> = inspect_config
1894        .privilege_schemas
1895        .iter()
1896        .map(|schema| schema.as_str())
1897        .collect();
1898    let relation_inventory =
1899        pgroles_inspect::fetch_relation_inventory(pool, &privilege_schemas).await?;
1900    Ok(
1901        pgroles_core::sql::SqlContext::from_version_num(pg_version.version_num)
1902            .with_relation_inventory(relation_inventory),
1903    )
1904}
1905
1906fn render_plan_sql_for_status(
1907    changes: &[pgroles_core::diff::Change],
1908    sql_ctx: &pgroles_core::sql::SqlContext,
1909) -> (Option<String>, bool) {
1910    if changes.is_empty() {
1911        return (None, false);
1912    }
1913
1914    // Render each change individually so we can redact passwords.
1915    let rendered: String = changes
1916        .iter()
1917        .flat_map(|change| {
1918            if let pgroles_core::diff::Change::SetPassword { name, .. } = change {
1919                vec![format!(
1920                    "ALTER ROLE {} PASSWORD '[REDACTED]';",
1921                    pgroles_core::sql::quote_ident(name)
1922                )]
1923            } else {
1924                pgroles_core::sql::render_statements_with_context(change, sql_ctx)
1925            }
1926        })
1927        .collect::<Vec<_>>()
1928        .join("\n");
1929
1930    let (truncated, did_truncate) = truncate_status_text(&rendered, MAX_PLANNED_SQL_STATUS_BYTES);
1931    (Some(truncated), did_truncate)
1932}
1933
1934fn truncate_status_text(text: &str, max_bytes: usize) -> (String, bool) {
1935    if text.len() <= max_bytes {
1936        return (text.to_string(), false);
1937    }
1938
1939    let marker = "\n-- truncated for status --";
1940    let target_len = max_bytes.saturating_sub(marker.len());
1941    let mut end = target_len.min(text.len());
1942    while end > 0 && !text.is_char_boundary(end) {
1943        end -= 1;
1944    }
1945
1946    let mut truncated = text[..end].to_string();
1947    truncated.push_str(marker);
1948    (truncated, true)
1949}
1950
1951/// Emit a plan lifecycle event on the parent policy, logging warnings on failure.
1952async fn emit_plan_event(
1953    ctx: &OperatorContext,
1954    policy: &PostgresPolicy,
1955    plan: &PostgresPolicyPlan,
1956    event_type: PlanEventType,
1957) {
1958    if let Err(error) = publish_plan_event(&ctx.event_recorder, policy, plan, event_type).await {
1959        let namespace = policy.namespace().unwrap_or_default();
1960        let name = policy.name_any();
1961        tracing::warn!(
1962            policy = %format!("{namespace}/{name}"),
1963            %error,
1964            "failed to publish plan lifecycle event"
1965        );
1966    }
1967}
1968
1969/// Patch the status sub-resource of a PostgresPolicy.
1970async fn update_status<F>(
1971    ctx: &OperatorContext,
1972    resource: &PostgresPolicy,
1973    mutate: F,
1974) -> Result<(), ReconcileError>
1975where
1976    F: FnOnce(&mut PostgresPolicyStatus),
1977{
1978    let namespace = resource.namespace().ok_or(ReconcileError::NoNamespace)?;
1979    let name = resource.name_any();
1980
1981    let api: Api<PostgresPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
1982    let latest = api.get(&name).await?;
1983    let old_status = latest.status.clone();
1984    let mut status = old_status.clone().unwrap_or_default();
1985
1986    mutate(&mut status);
1987
1988    let patch = serde_json::json!({
1989        "status": status
1990    });
1991
1992    api.patch_status(
1993        &name,
1994        &PatchParams::apply("pgroles-operator"),
1995        &Patch::Merge(&patch),
1996    )
1997    .await?;
1998
1999    if let Err(error) =
2000        publish_status_events(&ctx.event_recorder, &latest, old_status.as_ref(), &status).await
2001    {
2002        tracing::warn!(policy = %format!("{namespace}/{name}"), %error, "failed to publish Kubernetes Events");
2003    }
2004
2005    Ok(())
2006}
2007
2008async fn detect_policy_conflict(
2009    ctx: &OperatorContext,
2010    resource: &PostgresPolicy,
2011    identity: &DatabaseIdentity,
2012    ownership: &crate::crd::OwnershipClaims,
2013) -> Result<Option<String>, ReconcileError> {
2014    let api: Api<PostgresPolicy> = Api::all(ctx.kube_client.clone());
2015    let policies = api.list(&Default::default()).await?;
2016
2017    Ok(detect_policy_conflict_in_list(
2018        resource,
2019        identity,
2020        ownership,
2021        policies.into_iter(),
2022    ))
2023}
2024
2025fn detect_policy_conflict_in_list(
2026    resource: &PostgresPolicy,
2027    identity: &DatabaseIdentity,
2028    ownership: &crate::crd::OwnershipClaims,
2029    policies: impl IntoIterator<Item = PostgresPolicy>,
2030) -> Option<String> {
2031    let this_ns = resource.namespace()?;
2032    let this_name = resource.name_any();
2033
2034    let mut conflicts = Vec::new();
2035    for other in policies {
2036        let other_ns = match other.namespace() {
2037            Some(ns) => ns,
2038            None => continue,
2039        };
2040        let other_name = other.name_any();
2041        if other_ns == this_ns && other_name == this_name {
2042            continue;
2043        }
2044
2045        let other_identity = DatabaseIdentity::from_connection(&other_ns, &other.spec.connection);
2046        if &other_identity != identity {
2047            continue;
2048        }
2049
2050        if let Err(error) = other.spec.validate_password_specs(&other_name) {
2051            tracing::warn!(
2052                policy = %format!("{other_ns}/{other_name}"),
2053                database = %identity.as_str(),
2054                %error,
2055                "skipping conflict detection for invalid peer policy"
2056            );
2057            continue;
2058        }
2059
2060        let other_ownership = match other.spec.ownership_claims() {
2061            Ok(claims) => claims,
2062            Err(error) => {
2063                tracing::warn!(
2064                    policy = %format!("{other_ns}/{other_name}"),
2065                    database = %identity.as_str(),
2066                    %error,
2067                    "skipping conflict detection for invalid peer policy"
2068                );
2069                continue;
2070            }
2071        };
2072        if ownership.overlaps(&other_ownership) {
2073            let overlap = ownership.overlap_summary(&other_ownership);
2074            conflicts.push(format!("{other_ns}/{other_name} ({overlap})"));
2075        }
2076    }
2077
2078    if conflicts.is_empty() {
2079        None
2080    } else {
2081        Some(format!(
2082            "policy ownership overlaps with {} on database target {}",
2083            conflicts.join(", "),
2084            identity.as_str()
2085        ))
2086    }
2087}
2088
2089impl ReconcileError {
2090    fn reason(&self) -> &'static str {
2091        match self {
2092            ReconcileError::ManifestExpansion(_)
2093            | ReconcileError::InvalidInterval(_, _)
2094            | ReconcileError::InvalidSpec(_) => "InvalidSpec",
2095            ReconcileError::ConflictingPolicy(_) => "ConflictingPolicy",
2096            ReconcileError::UnsatisfiableWildcardGrant(_) => "UnsatisfiableWildcardGrant",
2097            ReconcileError::LockContention(_, _) => "LockContention",
2098            ReconcileError::Context(context) => match context.as_ref() {
2099                ContextError::SecretFetch { .. } => "SecretFetchFailed",
2100                ContextError::SecretMissing { .. } => "SecretMissing",
2101                ContextError::GcpAuthHttp { .. }
2102                | ContextError::GcpAuthRejected { .. }
2103                | ContextError::GcpAuthInvalidResponse { .. } => "GcpAuthFailed",
2104                ContextError::DatabaseConnect { .. } => "DatabaseConnectionFailed",
2105                ContextError::SetRoleFailed { .. } => "SetRoleFailed",
2106                ContextError::EmptyResolvedValue { .. } => "InvalidConnectionParams",
2107                ContextError::InvalidResolvedSslMode { .. } => "InvalidConnectionParams",
2108            },
2109            ReconcileError::Inspect(error) => match error {
2110                pgroles_inspect::InspectError::Database(sql_err) => {
2111                    match classify_sqlx_error(sql_err) {
2112                        SqlErrorKind::InsufficientPrivileges => "InsufficientPrivileges",
2113                        SqlErrorKind::MissingDatabaseObject => "MissingDatabaseObject",
2114                        SqlErrorKind::Transient => "DatabaseInspectionFailed",
2115                    }
2116                }
2117            },
2118            ReconcileError::SqlExec(error) => match classify_sqlx_error(error) {
2119                SqlErrorKind::InsufficientPrivileges => "InsufficientPrivileges",
2120                SqlErrorKind::MissingDatabaseObject => "MissingDatabaseObject",
2121                SqlErrorKind::Transient => "ApplyFailed",
2122            },
2123            ReconcileError::UnsafeRoleDrops(_) => "UnsafeRoleDrops",
2124            ReconcileError::EmptyPasswordSecret { .. } => "InvalidSpec",
2125            ReconcileError::MissingDatabaseObjects(_) => "MissingDatabaseObject",
2126            ReconcileError::PasswordGeneration(_) => "SecretFetchFailed",
2127            ReconcileError::PlanSqlStorage(_) => "PlanSqlStorageFailed",
2128            ReconcileError::Kube(_) => "KubernetesApiError",
2129            ReconcileError::NoNamespace => "InvalidResource",
2130        }
2131    }
2132}
2133
2134// ---------------------------------------------------------------------------
2135// Tests
2136// ---------------------------------------------------------------------------
2137
2138#[cfg(test)]
2139mod tests {
2140    use super::*;
2141    use crate::crd::{
2142        ConnectionSpec, CrdReconciliationMode, PasswordSpec, PolicyMode, PostgresPolicySpec,
2143        RoleSpec, SecretReference,
2144    };
2145    use k8s_openapi::{
2146        ByteString, api::core::v1::Secret, apimachinery::pkg::apis::meta::v1::ObjectMeta,
2147    };
2148    use sqlx::error::{DatabaseError, ErrorKind};
2149    use std::borrow::Cow;
2150    use std::collections::BTreeMap;
2151    use std::error::Error as StdError;
2152    use std::fmt;
2153
2154    #[derive(Debug)]
2155    struct TestDatabaseError {
2156        message: String,
2157        code: Option<&'static str>,
2158    }
2159
2160    impl fmt::Display for TestDatabaseError {
2161        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2162            f.write_str(&self.message)
2163        }
2164    }
2165
2166    impl StdError for TestDatabaseError {}
2167
2168    impl DatabaseError for TestDatabaseError {
2169        fn message(&self) -> &str {
2170            &self.message
2171        }
2172
2173        fn code(&self) -> Option<Cow<'_, str>> {
2174            self.code.map(Cow::Borrowed)
2175        }
2176
2177        fn as_error(&self) -> &(dyn StdError + Send + Sync + 'static) {
2178            self
2179        }
2180
2181        fn as_error_mut(&mut self) -> &mut (dyn StdError + Send + Sync + 'static) {
2182            self
2183        }
2184
2185        fn into_error(self: Box<Self>) -> Box<dyn StdError + Send + Sync + 'static> {
2186            self
2187        }
2188
2189        fn kind(&self) -> ErrorKind {
2190            ErrorKind::Other
2191        }
2192    }
2193
2194    fn insufficient_privilege_sqlx_error() -> sqlx::Error {
2195        sqlx::Error::Database(Box::new(TestDatabaseError {
2196            message: "permission denied to create role".to_string(),
2197            code: Some(SQLSTATE_INSUFFICIENT_PRIVILEGE),
2198        }))
2199    }
2200
2201    fn missing_schema_sqlx_error() -> sqlx::Error {
2202        sqlx::Error::Database(Box::new(TestDatabaseError {
2203            message: "schema \"etl\" does not exist".to_string(),
2204            code: Some(SQLSTATE_INVALID_SCHEMA_NAME),
2205        }))
2206    }
2207
2208    fn missing_table_sqlx_error() -> sqlx::Error {
2209        sqlx::Error::Database(Box::new(TestDatabaseError {
2210            message: "relation \"foo\" does not exist".to_string(),
2211            code: Some(SQLSTATE_UNDEFINED_TABLE),
2212        }))
2213    }
2214
2215    fn missing_function_sqlx_error() -> sqlx::Error {
2216        sqlx::Error::Database(Box::new(TestDatabaseError {
2217            message: "function foo() does not exist".to_string(),
2218            code: Some(SQLSTATE_UNDEFINED_FUNCTION),
2219        }))
2220    }
2221
2222    fn missing_object_sqlx_error() -> sqlx::Error {
2223        sqlx::Error::Database(Box::new(TestDatabaseError {
2224            message: "role \"nope\" does not exist".to_string(),
2225            code: Some(SQLSTATE_UNDEFINED_OBJECT),
2226        }))
2227    }
2228
2229    fn transient_sqlx_error() -> sqlx::Error {
2230        sqlx::Error::Database(Box::new(TestDatabaseError {
2231            message: "connection timed out".to_string(),
2232            code: Some("08006"),
2233        }))
2234    }
2235
2236    fn test_policy(interval: &str, transient_failure_count: i32) -> Arc<PostgresPolicy> {
2237        let spec = PostgresPolicySpec {
2238            connection: ConnectionSpec {
2239                secret_ref: Some(SecretReference {
2240                    name: "db-credentials".to_string(),
2241                }),
2242                secret_key: Some("DATABASE_URL".to_string()),
2243                params: None,
2244            },
2245            interval: interval.to_string(),
2246            suspend: false,
2247            mode: PolicyMode::Apply,
2248            reconciliation_mode: CrdReconciliationMode::default(),
2249            default_owner: None,
2250            profiles: Default::default(),
2251            schemas: Vec::new(),
2252            roles: Vec::new(),
2253            grants: Vec::new(),
2254            default_privileges: Vec::new(),
2255            memberships: Vec::new(),
2256            retirements: Vec::new(),
2257            approval: None,
2258        };
2259        let mut resource = PostgresPolicy::new("example", spec);
2260        resource.metadata.namespace = Some("default".to_string());
2261        resource.status = Some(PostgresPolicyStatus {
2262            transient_failure_count,
2263            ..Default::default()
2264        });
2265        Arc::new(resource)
2266    }
2267
2268    fn test_policy_with_spec(name: &str, spec: PostgresPolicySpec) -> PostgresPolicy {
2269        let mut resource = PostgresPolicy::new(name, spec);
2270        resource.metadata.namespace = Some("default".to_string());
2271        resource
2272    }
2273
2274    fn valid_role_policy(name: &str, role_name: &str, secret_name: &str) -> PostgresPolicy {
2275        test_policy_with_spec(
2276            name,
2277            PostgresPolicySpec {
2278                connection: ConnectionSpec {
2279                    secret_ref: Some(SecretReference {
2280                        name: secret_name.to_string(),
2281                    }),
2282                    secret_key: Some("DATABASE_URL".to_string()),
2283                    params: None,
2284                },
2285                interval: "5m".to_string(),
2286                suspend: false,
2287                mode: PolicyMode::Apply,
2288                reconciliation_mode: CrdReconciliationMode::default(),
2289                default_owner: None,
2290                profiles: Default::default(),
2291                schemas: Vec::new(),
2292                roles: vec![RoleSpec {
2293                    name: role_name.to_string(),
2294                    login: Some(true),
2295                    superuser: None,
2296                    createdb: None,
2297                    createrole: None,
2298                    inherit: None,
2299                    replication: None,
2300                    bypassrls: None,
2301                    connection_limit: None,
2302                    comment: None,
2303                    password: None,
2304                    password_valid_until: None,
2305                }],
2306                grants: Vec::new(),
2307                default_privileges: Vec::new(),
2308                memberships: Vec::new(),
2309                retirements: Vec::new(),
2310                approval: None,
2311            },
2312        )
2313    }
2314
2315    fn invalid_profile_policy(name: &str, secret_name: &str) -> PostgresPolicy {
2316        test_policy_with_spec(
2317            name,
2318            PostgresPolicySpec {
2319                connection: ConnectionSpec {
2320                    secret_ref: Some(SecretReference {
2321                        name: secret_name.to_string(),
2322                    }),
2323                    secret_key: Some("DATABASE_URL".to_string()),
2324                    params: None,
2325                },
2326                interval: "5m".to_string(),
2327                suspend: false,
2328                mode: PolicyMode::Apply,
2329                reconciliation_mode: CrdReconciliationMode::default(),
2330                default_owner: None,
2331                profiles: Default::default(),
2332                schemas: vec![pgroles_core::manifest::SchemaBinding {
2333                    name: "reporting".to_string(),
2334                    profiles: vec!["missing-profile".to_string()],
2335                    role_pattern: "{schema}-{profile}".to_string(),
2336                    owner: None,
2337                }],
2338                roles: Vec::new(),
2339                grants: Vec::new(),
2340                default_privileges: Vec::new(),
2341                memberships: Vec::new(),
2342                retirements: Vec::new(),
2343                approval: None,
2344            },
2345        )
2346    }
2347
2348    fn password_role_policy() -> PostgresPolicy {
2349        test_policy_with_spec(
2350            "password-policy",
2351            PostgresPolicySpec {
2352                connection: ConnectionSpec {
2353                    secret_ref: Some(SecretReference {
2354                        name: "db-credentials".to_string(),
2355                    }),
2356                    secret_key: Some("DATABASE_URL".to_string()),
2357                    params: None,
2358                },
2359                interval: "5m".to_string(),
2360                suspend: false,
2361                mode: PolicyMode::Apply,
2362                reconciliation_mode: CrdReconciliationMode::default(),
2363                default_owner: None,
2364                profiles: Default::default(),
2365                schemas: Vec::new(),
2366                roles: vec![
2367                    RoleSpec {
2368                        name: "app".to_string(),
2369                        login: Some(true),
2370                        superuser: None,
2371                        createdb: None,
2372                        createrole: None,
2373                        inherit: None,
2374                        replication: None,
2375                        bypassrls: None,
2376                        connection_limit: None,
2377                        comment: None,
2378                        password: Some(PasswordSpec {
2379                            secret_ref: Some(SecretReference {
2380                                name: "role-passwords".to_string(),
2381                            }),
2382                            secret_key: None,
2383                            generate: None,
2384                        }),
2385                        password_valid_until: None,
2386                    },
2387                    RoleSpec {
2388                        name: "reporter".to_string(),
2389                        login: Some(true),
2390                        superuser: None,
2391                        createdb: None,
2392                        createrole: None,
2393                        inherit: None,
2394                        replication: None,
2395                        bypassrls: None,
2396                        connection_limit: None,
2397                        comment: None,
2398                        password: Some(PasswordSpec {
2399                            secret_ref: Some(SecretReference {
2400                                name: "role-passwords".to_string(),
2401                            }),
2402                            secret_key: Some("reporter-password".to_string()),
2403                            generate: None,
2404                        }),
2405                        password_valid_until: None,
2406                    },
2407                ],
2408                grants: Vec::new(),
2409                default_privileges: Vec::new(),
2410                memberships: Vec::new(),
2411                retirements: Vec::new(),
2412                approval: None,
2413            },
2414        )
2415    }
2416
2417    fn secret_with_keys(name: &str, entries: &[(&str, &str)]) -> Secret {
2418        secret_with_keys_and_version(name, "1", entries)
2419    }
2420
2421    fn secret_with_keys_and_version(
2422        name: &str,
2423        resource_version: &str,
2424        entries: &[(&str, &str)],
2425    ) -> Secret {
2426        Secret {
2427            metadata: ObjectMeta {
2428                name: Some(name.to_string()),
2429                resource_version: Some(resource_version.to_string()),
2430                ..Default::default()
2431            },
2432            data: Some(
2433                entries
2434                    .iter()
2435                    .map(|(key, value)| ((*key).to_string(), ByteString(value.as_bytes().to_vec())))
2436                    .collect(),
2437            ),
2438            ..Default::default()
2439        }
2440    }
2441
2442    #[test]
2443    fn parse_interval_minutes() {
2444        let d = parse_interval("5m").unwrap();
2445        assert_eq!(d, Duration::from_secs(300));
2446    }
2447
2448    #[test]
2449    fn parse_interval_hours() {
2450        let d = parse_interval("1h").unwrap();
2451        assert_eq!(d, Duration::from_secs(3600));
2452    }
2453
2454    #[test]
2455    fn parse_interval_seconds() {
2456        let d = parse_interval("30s").unwrap();
2457        assert_eq!(d, Duration::from_secs(30));
2458    }
2459
2460    #[test]
2461    fn parse_interval_compound() {
2462        let d = parse_interval("1h30m").unwrap();
2463        assert_eq!(d, Duration::from_secs(5400));
2464    }
2465
2466    #[test]
2467    fn parse_interval_empty_uses_default() {
2468        let d = parse_interval("").unwrap();
2469        assert_eq!(d, Duration::from_secs(DEFAULT_REQUEUE_SECS));
2470    }
2471
2472    #[test]
2473    fn parse_interval_bare_number_treated_as_seconds() {
2474        let d = parse_interval("120").unwrap();
2475        assert_eq!(d, Duration::from_secs(120));
2476    }
2477
2478    #[test]
2479    fn parse_interval_invalid_unit() {
2480        let result = parse_interval("5x");
2481        assert!(result.is_err());
2482    }
2483
2484    #[test]
2485    fn accumulate_summary_counts() {
2486        use pgroles_core::diff::Change;
2487        use pgroles_core::model::RoleState;
2488
2489        let mut summary = ChangeSummary::default();
2490
2491        accumulate_summary(
2492            &mut summary,
2493            &Change::CreateRole {
2494                name: "test".to_string(),
2495                state: RoleState {
2496                    login: true,
2497                    ..RoleState::default()
2498                },
2499            },
2500        );
2501        accumulate_summary(
2502            &mut summary,
2503            &Change::Grant {
2504                role: "test".to_string(),
2505                object_type: pgroles_core::manifest::ObjectType::Schema,
2506                schema: None,
2507                name: Some("public".to_string()),
2508                privileges: [pgroles_core::manifest::Privilege::Usage]
2509                    .into_iter()
2510                    .collect(),
2511            },
2512        );
2513        accumulate_summary(
2514            &mut summary,
2515            &Change::TerminateSessions {
2516                role: "test".to_string(),
2517            },
2518        );
2519
2520        assert_eq!(summary.roles_created, 1);
2521        assert_eq!(summary.grants_added, 1);
2522        assert_eq!(summary.sessions_terminated, 1);
2523    }
2524
2525    #[test]
2526    fn accumulate_summary_counts_schema_changes_separately() {
2527        use pgroles_core::diff::Change;
2528
2529        let mut summary = ChangeSummary::default();
2530
2531        accumulate_summary(
2532            &mut summary,
2533            &Change::CreateSchema {
2534                name: "inventory".to_string(),
2535                owner: Some("inventory_owner".to_string()),
2536            },
2537        );
2538        accumulate_summary(
2539            &mut summary,
2540            &Change::AlterSchemaOwner {
2541                name: "catalog".to_string(),
2542                owner: "catalog_owner".to_string(),
2543            },
2544        );
2545
2546        assert_eq!(summary.schemas_created, 1);
2547        assert_eq!(summary.schema_owners_altered, 1);
2548        assert_eq!(summary.grants_added, 0);
2549    }
2550
2551    #[test]
2552    fn summarize_changes_sets_total() {
2553        use pgroles_core::diff::Change;
2554        use pgroles_core::model::RoleState;
2555
2556        let changes = vec![
2557            Change::CreateRole {
2558                name: "test".to_string(),
2559                state: RoleState::default(),
2560            },
2561            Change::CreateSchema {
2562                name: "inventory".to_string(),
2563                owner: Some("inventory_owner".to_string()),
2564            },
2565            Change::Grant {
2566                role: "test".to_string(),
2567                object_type: pgroles_core::manifest::ObjectType::Schema,
2568                schema: None,
2569                name: Some("public".to_string()),
2570                privileges: [pgroles_core::manifest::Privilege::Usage]
2571                    .into_iter()
2572                    .collect(),
2573            },
2574        ];
2575
2576        let summary = summarize_changes(&changes);
2577        assert_eq!(summary.roles_created, 1);
2578        assert_eq!(summary.schemas_created, 1);
2579        assert_eq!(summary.grants_added, 1);
2580        assert_eq!(summary.total, 3);
2581    }
2582
2583    #[test]
2584    fn truncate_status_text_marks_truncation() {
2585        let text = "x".repeat(MAX_PLANNED_SQL_STATUS_BYTES + 32);
2586        let (truncated, did_truncate) = truncate_status_text(&text, MAX_PLANNED_SQL_STATUS_BYTES);
2587        assert!(did_truncate);
2588        assert!(truncated.len() <= MAX_PLANNED_SQL_STATUS_BYTES);
2589        assert!(truncated.ends_with("-- truncated for status --"));
2590    }
2591
2592    #[test]
2593    fn accumulate_summary_all_change_types() {
2594        use pgroles_core::diff::Change;
2595        use pgroles_core::model::RoleState;
2596
2597        let mut summary = ChangeSummary::default();
2598
2599        accumulate_summary(
2600            &mut summary,
2601            &Change::CreateRole {
2602                name: "r1".to_string(),
2603                state: RoleState::default(),
2604            },
2605        );
2606        accumulate_summary(
2607            &mut summary,
2608            &Change::AlterRole {
2609                name: "r1".to_string(),
2610                attributes: vec![pgroles_core::model::RoleAttribute::Login(true)],
2611            },
2612        );
2613        accumulate_summary(
2614            &mut summary,
2615            &Change::CreateSchema {
2616                name: "schema1".to_string(),
2617                owner: Some("owner1".to_string()),
2618            },
2619        );
2620        accumulate_summary(
2621            &mut summary,
2622            &Change::AlterSchemaOwner {
2623                name: "schema2".to_string(),
2624                owner: "owner2".to_string(),
2625            },
2626        );
2627        accumulate_summary(
2628            &mut summary,
2629            &Change::SetComment {
2630                name: "r1".to_string(),
2631                comment: Some("comment".to_string()),
2632            },
2633        );
2634        accumulate_summary(
2635            &mut summary,
2636            &Change::DropRole {
2637                name: "r1".to_string(),
2638            },
2639        );
2640        accumulate_summary(
2641            &mut summary,
2642            &Change::TerminateSessions {
2643                role: "r1".to_string(),
2644            },
2645        );
2646        accumulate_summary(
2647            &mut summary,
2648            &Change::ReassignOwned {
2649                from_role: "r1".to_string(),
2650                to_role: "r2".to_string(),
2651            },
2652        );
2653        accumulate_summary(
2654            &mut summary,
2655            &Change::DropOwned {
2656                role: "r1".to_string(),
2657            },
2658        );
2659        accumulate_summary(
2660            &mut summary,
2661            &Change::Grant {
2662                role: "r1".to_string(),
2663                object_type: pgroles_core::manifest::ObjectType::Table,
2664                schema: Some("public".to_string()),
2665                name: Some("*".to_string()),
2666                privileges: [pgroles_core::manifest::Privilege::Select]
2667                    .into_iter()
2668                    .collect(),
2669            },
2670        );
2671        accumulate_summary(
2672            &mut summary,
2673            &Change::Revoke {
2674                role: "r1".to_string(),
2675                object_type: pgroles_core::manifest::ObjectType::Table,
2676                schema: Some("public".to_string()),
2677                name: Some("*".to_string()),
2678                privileges: [pgroles_core::manifest::Privilege::Select]
2679                    .into_iter()
2680                    .collect(),
2681            },
2682        );
2683        accumulate_summary(
2684            &mut summary,
2685            &Change::SetDefaultPrivilege {
2686                schema: "public".to_string(),
2687                owner: "owner".to_string(),
2688                grantee: "r1".to_string(),
2689                on_type: pgroles_core::manifest::ObjectType::Table,
2690                privileges: [pgroles_core::manifest::Privilege::Select]
2691                    .into_iter()
2692                    .collect(),
2693            },
2694        );
2695        accumulate_summary(
2696            &mut summary,
2697            &Change::RevokeDefaultPrivilege {
2698                schema: "public".to_string(),
2699                owner: "owner".to_string(),
2700                grantee: "r1".to_string(),
2701                on_type: pgroles_core::manifest::ObjectType::Table,
2702                privileges: [pgroles_core::manifest::Privilege::Select]
2703                    .into_iter()
2704                    .collect(),
2705            },
2706        );
2707        accumulate_summary(
2708            &mut summary,
2709            &Change::AddMember {
2710                role: "r1".to_string(),
2711                member: "r2".to_string(),
2712                inherit: true,
2713                admin: false,
2714            },
2715        );
2716        accumulate_summary(
2717            &mut summary,
2718            &Change::RemoveMember {
2719                role: "r1".to_string(),
2720                member: "r2".to_string(),
2721            },
2722        );
2723
2724        assert_eq!(summary.roles_created, 1);
2725        // AlterRole + SetComment both increment roles_altered
2726        assert_eq!(summary.roles_altered, 2);
2727        assert_eq!(summary.schemas_created, 1);
2728        assert_eq!(summary.schema_owners_altered, 1);
2729        assert_eq!(summary.roles_dropped, 1);
2730        assert_eq!(summary.sessions_terminated, 1);
2731        assert_eq!(summary.grants_added, 1);
2732        assert_eq!(summary.grants_revoked, 1);
2733        assert_eq!(summary.default_privileges_set, 1);
2734        assert_eq!(summary.default_privileges_revoked, 1);
2735        assert_eq!(summary.members_added, 1);
2736        assert_eq!(summary.members_removed, 1);
2737    }
2738
2739    #[test]
2740    fn error_reason_invalid_spec_for_manifest_expansion() {
2741        let err = ReconcileError::ManifestExpansion(
2742            pgroles_core::manifest::ManifestError::UndefinedProfile("bad".into(), "schema1".into()),
2743        );
2744        assert_eq!(err.reason(), "InvalidSpec");
2745    }
2746
2747    #[test]
2748    fn error_reason_invalid_spec_for_invalid_interval() {
2749        let err = ReconcileError::InvalidInterval("5x".into(), "unknown unit 'x'".into());
2750        assert_eq!(err.reason(), "InvalidSpec");
2751    }
2752
2753    #[test]
2754    fn error_reason_invalid_spec_for_password_validation() {
2755        let err = ReconcileError::InvalidSpec("role password must set exactly one mode".into());
2756        assert_eq!(err.reason(), "InvalidSpec");
2757    }
2758
2759    #[test]
2760    fn error_reason_missing_database_objects() {
2761        let err = ReconcileError::MissingDatabaseObjects("schema \"etl\"".into());
2762        assert_eq!(err.reason(), "MissingDatabaseObject");
2763    }
2764
2765    #[test]
2766    fn error_reason_unsatisfiable_wildcard_grant() {
2767        let err = ReconcileError::UnsatisfiableWildcardGrant(
2768            "UnsatisfiableWildcardGrant: function f2() is not grantable".into(),
2769        );
2770        assert_eq!(err.reason(), "UnsatisfiableWildcardGrant");
2771        assert!(err.to_string().contains("UnsatisfiableWildcardGrant"));
2772    }
2773
2774    #[test]
2775    fn unsatisfiable_wildcard_status_is_degraded_without_plan_reference() {
2776        let message = "UnsatisfiableWildcardGrant: cannot fully satisfy wildcard grant EXECUTE ON function * IN SCHEMA \"app\" TO \"reader\" as executor \"app_owner\"; 1 matching object(s) are missing the desired privilege and are not grantable (examples: \"f2()\" owned by \"definer\" missing [EXECUTE])";
2777        let mut status = PostgresPolicyStatus {
2778            conditions: vec![
2779                ready_condition(true, "Planned", "Plan computed"),
2780                conflict_condition("ConflictingPolicy", "Policy overlaps another policy"),
2781                reconciling_condition("Reconciliation in progress"),
2782                drifted_condition(true, "DriftDetected", "1 planned change pending"),
2783            ],
2784            change_summary: Some(ChangeSummary {
2785                grants_added: 1,
2786                total: 1,
2787                ..Default::default()
2788            }),
2789            planned_sql: Some(
2790                "GRANT EXECUTE ON ALL ROUTINES IN SCHEMA \"app\" TO \"reader\";".into(),
2791            ),
2792            planned_sql_truncated: true,
2793            last_error: None,
2794            transient_failure_count: 3,
2795            current_plan_ref: Some(crate::crd::PlanReference {
2796                name: "example-plan".into(),
2797            }),
2798            ..Default::default()
2799        };
2800
2801        mark_reconcile_failure_status(
2802            &mut status,
2803            "UnsatisfiableWildcardGrant",
2804            message,
2805            false,
2806            true,
2807        );
2808
2809        let ready = status
2810            .conditions
2811            .iter()
2812            .find(|condition| condition.condition_type == "Ready")
2813            .expect("Ready condition should be present");
2814        assert_eq!(ready.status, "False");
2815        assert_eq!(ready.reason.as_deref(), Some("UnsatisfiableWildcardGrant"));
2816        assert_eq!(ready.message.as_deref(), Some(message));
2817
2818        let degraded = status
2819            .conditions
2820            .iter()
2821            .find(|condition| condition.condition_type == "Degraded")
2822            .expect("Degraded condition should be present");
2823        assert_eq!(degraded.status, "True");
2824        assert_eq!(
2825            degraded.reason.as_deref(),
2826            Some("UnsatisfiableWildcardGrant")
2827        );
2828        assert_eq!(degraded.message.as_deref(), Some(message));
2829
2830        assert!(
2831            status.conditions.iter().all(|condition| {
2832                condition.condition_type != "Reconciling"
2833                    && condition.condition_type != "Drifted"
2834                    && condition.condition_type != "Conflict"
2835            }),
2836            "transient planning and stale conflict conditions should be cleared on degraded status"
2837        );
2838        assert!(status.change_summary.is_none());
2839        assert!(status.planned_sql.is_none());
2840        assert!(!status.planned_sql_truncated);
2841        assert!(status.current_plan_ref.is_none());
2842        assert_eq!(status.last_error.as_deref(), Some(message));
2843        assert_eq!(status.transient_failure_count, 0);
2844    }
2845
2846    #[test]
2847    fn reconcile_failure_status_preserves_plan_reference_when_requested() {
2848        let mut status = PostgresPolicyStatus {
2849            current_plan_ref: Some(crate::crd::PlanReference {
2850                name: "approved-plan".into(),
2851            }),
2852            planned_sql: Some("ALTER ROLE \"app\" LOGIN;".into()),
2853            planned_sql_truncated: true,
2854            transient_failure_count: 2,
2855            ..Default::default()
2856        };
2857
2858        mark_reconcile_failure_status(
2859            &mut status,
2860            "ApplyFailed",
2861            "SQL execution error: connection closed",
2862            true,
2863            false,
2864        );
2865
2866        assert_eq!(
2867            status
2868                .current_plan_ref
2869                .as_ref()
2870                .map(|plan| plan.name.as_str()),
2871            Some("approved-plan")
2872        );
2873        assert!(status.planned_sql.is_none());
2874        assert!(!status.planned_sql_truncated);
2875        assert_eq!(
2876            status.last_error.as_deref(),
2877            Some("SQL execution error: connection closed")
2878        );
2879        assert_eq!(status.transient_failure_count, 3);
2880    }
2881
2882    #[test]
2883    fn error_display_missing_database_objects_lists_schemas() {
2884        let err = ReconcileError::MissingDatabaseObjects("schema \"etl\", schema \"jobs\"".into());
2885        let msg = err.to_string();
2886        assert!(msg.contains("schema \"etl\""));
2887        assert!(msg.contains("schema \"jobs\""));
2888        assert!(
2889            msg.contains("pointing at the intended database"),
2890            "message should include remediation hint"
2891        );
2892    }
2893
2894    #[test]
2895    fn referenced_schema_names_from_schema_grants() {
2896        use pgroles_core::manifest::{
2897            ExpandedManifest, Grant, ObjectTarget, ObjectType, Privilege,
2898        };
2899        let expanded = ExpandedManifest {
2900            schemas: Vec::new(),
2901            roles: Vec::new(),
2902            grants: vec![Grant {
2903                role: "app".into(),
2904                privileges: vec![Privilege::Usage],
2905                object: ObjectTarget {
2906                    object_type: ObjectType::Schema,
2907                    schema: None,
2908                    name: Some("etl".into()),
2909                },
2910            }],
2911            default_privileges: Vec::new(),
2912            memberships: Vec::new(),
2913        };
2914        let names = referenced_schema_names(&expanded);
2915        assert!(names.contains("etl"));
2916    }
2917
2918    #[test]
2919    fn referenced_schema_names_from_table_grants() {
2920        use pgroles_core::manifest::{
2921            ExpandedManifest, Grant, ObjectTarget, ObjectType, Privilege,
2922        };
2923        let expanded = ExpandedManifest {
2924            schemas: Vec::new(),
2925            roles: Vec::new(),
2926            grants: vec![Grant {
2927                role: "app".into(),
2928                privileges: vec![Privilege::Select],
2929                object: ObjectTarget {
2930                    object_type: ObjectType::Table,
2931                    schema: Some("analytics".into()),
2932                    name: Some("*".into()),
2933                },
2934            }],
2935            default_privileges: Vec::new(),
2936            memberships: Vec::new(),
2937        };
2938        let names = referenced_schema_names(&expanded);
2939        assert!(names.contains("analytics"));
2940    }
2941
2942    #[test]
2943    fn referenced_schema_names_from_default_privileges() {
2944        use pgroles_core::manifest::{
2945            DefaultPrivilege, DefaultPrivilegeGrant, ExpandedManifest, ObjectType, Privilege,
2946        };
2947        let expanded = ExpandedManifest {
2948            schemas: Vec::new(),
2949            roles: Vec::new(),
2950            grants: Vec::new(),
2951            default_privileges: vec![DefaultPrivilege {
2952                owner: Some("app_owner".into()),
2953                schema: "reporting".into(),
2954                grant: vec![DefaultPrivilegeGrant {
2955                    role: Some("app".into()),
2956                    privileges: vec![Privilege::Select],
2957                    on_type: ObjectType::Table,
2958                }],
2959            }],
2960            memberships: Vec::new(),
2961        };
2962        let names = referenced_schema_names(&expanded);
2963        assert!(names.contains("reporting"));
2964    }
2965
2966    #[test]
2967    fn referenced_schema_names_deduplicates_across_sources() {
2968        use pgroles_core::manifest::{
2969            DefaultPrivilege, DefaultPrivilegeGrant, ExpandedManifest, Grant, ObjectTarget,
2970            ObjectType, Privilege,
2971        };
2972        let expanded = ExpandedManifest {
2973            schemas: Vec::new(),
2974            roles: Vec::new(),
2975            grants: vec![
2976                Grant {
2977                    role: "app".into(),
2978                    privileges: vec![Privilege::Usage],
2979                    object: ObjectTarget {
2980                        object_type: ObjectType::Schema,
2981                        schema: None,
2982                        name: Some("shared".into()),
2983                    },
2984                },
2985                Grant {
2986                    role: "app".into(),
2987                    privileges: vec![Privilege::Select],
2988                    object: ObjectTarget {
2989                        object_type: ObjectType::Table,
2990                        schema: Some("shared".into()),
2991                        name: Some("*".into()),
2992                    },
2993                },
2994            ],
2995            default_privileges: vec![DefaultPrivilege {
2996                owner: Some("app_owner".into()),
2997                schema: "shared".into(),
2998                grant: vec![DefaultPrivilegeGrant {
2999                    role: Some("app".into()),
3000                    privileges: vec![Privilege::Select],
3001                    on_type: ObjectType::Table,
3002                }],
3003            }],
3004            memberships: Vec::new(),
3005        };
3006        let names = referenced_schema_names(&expanded);
3007        // BTreeSet deduplicates so a schema referenced three ways appears once.
3008        assert_eq!(names.len(), 1);
3009        assert!(names.contains("shared"));
3010    }
3011
3012    #[test]
3013    fn referenced_schema_names_skips_database_and_roleless_grants() {
3014        use pgroles_core::manifest::{
3015            ExpandedManifest, Grant, ObjectTarget, ObjectType, Privilege,
3016        };
3017        let expanded = ExpandedManifest {
3018            schemas: Vec::new(),
3019            roles: Vec::new(),
3020            grants: vec![Grant {
3021                role: "app".into(),
3022                privileges: vec![Privilege::Connect],
3023                object: ObjectTarget {
3024                    object_type: ObjectType::Database,
3025                    schema: None,
3026                    name: Some("mydb".into()),
3027                },
3028            }],
3029            default_privileges: Vec::new(),
3030            memberships: Vec::new(),
3031        };
3032        let names = referenced_schema_names(&expanded);
3033        assert!(
3034            names.is_empty(),
3035            "database-level grants should not contribute schema names"
3036        );
3037    }
3038
3039    #[test]
3040    fn is_system_schema_identifies_pg_and_information_schema() {
3041        assert!(is_system_schema("pg_catalog"));
3042        assert!(is_system_schema("pg_toast"));
3043        assert!(is_system_schema("pg_temp_1"));
3044        assert!(is_system_schema("information_schema"));
3045        assert!(!is_system_schema("public"));
3046        assert!(!is_system_schema("etl"));
3047        assert!(!is_system_schema("analytics"));
3048    }
3049
3050    #[test]
3051    fn referenced_schema_names_include_declared_schemas() {
3052        use pgroles_core::manifest::{ExpandedManifest, ExpandedSchema};
3053
3054        let expanded = ExpandedManifest {
3055            schemas: vec![ExpandedSchema {
3056                name: "cdc".into(),
3057                owner: Some("cdc_owner".into()),
3058            }],
3059            roles: Vec::new(),
3060            grants: Vec::new(),
3061            default_privileges: Vec::new(),
3062            memberships: Vec::new(),
3063        };
3064
3065        let names = referenced_schema_names(&expanded);
3066        assert!(names.contains("cdc"));
3067    }
3068
3069    #[test]
3070    fn declared_schema_names_returns_declared_only() {
3071        use pgroles_core::manifest::{ExpandedManifest, ExpandedSchema};
3072
3073        let expanded = ExpandedManifest {
3074            schemas: vec![ExpandedSchema {
3075                name: "cdc".into(),
3076                owner: Some("cdc_owner".into()),
3077            }],
3078            roles: Vec::new(),
3079            grants: Vec::new(),
3080            default_privileges: Vec::new(),
3081            memberships: Vec::new(),
3082        };
3083
3084        let names = declared_schema_names(&expanded);
3085        assert_eq!(names.len(), 1);
3086        assert!(names.contains("cdc"));
3087    }
3088
3089    #[test]
3090    fn externally_required_schema_names_excludes_declared_schemas() {
3091        use pgroles_core::manifest::{
3092            ExpandedManifest, ExpandedSchema, Grant, ObjectTarget, ObjectType, Privilege,
3093        };
3094
3095        let expanded = ExpandedManifest {
3096            schemas: vec![ExpandedSchema {
3097                name: "managed".into(),
3098                owner: Some("managed_owner".into()),
3099            }],
3100            roles: Vec::new(),
3101            grants: vec![
3102                Grant {
3103                    role: "app".into(),
3104                    privileges: vec![Privilege::Usage],
3105                    object: ObjectTarget {
3106                        object_type: ObjectType::Schema,
3107                        schema: None,
3108                        name: Some("managed".into()),
3109                    },
3110                },
3111                Grant {
3112                    role: "app".into(),
3113                    privileges: vec![Privilege::Select],
3114                    object: ObjectTarget {
3115                        object_type: ObjectType::Table,
3116                        schema: Some("external".into()),
3117                        name: Some("*".into()),
3118                    },
3119                },
3120            ],
3121            default_privileges: Vec::new(),
3122            memberships: Vec::new(),
3123        };
3124
3125        let names = externally_required_schema_names(&expanded);
3126        assert_eq!(names.len(), 1);
3127        assert!(names.contains("external"));
3128        assert!(!names.contains("managed"));
3129    }
3130
3131    #[test]
3132    fn error_reason_conflicting_policy() {
3133        let err = ReconcileError::ConflictingPolicy("overlaps with other".into());
3134        assert_eq!(err.reason(), "ConflictingPolicy");
3135    }
3136
3137    #[test]
3138    fn error_reason_unsafe_role_drops() {
3139        let err = ReconcileError::UnsafeRoleDrops("role owns objects".into());
3140        assert_eq!(err.reason(), "UnsafeRoleDrops");
3141    }
3142
3143    #[test]
3144    fn error_reason_no_namespace() {
3145        let err = ReconcileError::NoNamespace;
3146        assert_eq!(err.reason(), "InvalidResource");
3147    }
3148
3149    #[test]
3150    fn error_reason_context_secret_missing() {
3151        let err = ReconcileError::Context(Box::new(crate::context::ContextError::SecretMissing {
3152            name: "pg-secret".into(),
3153            key: "DATABASE_URL".into(),
3154        }));
3155        assert_eq!(err.reason(), "SecretMissing");
3156    }
3157
3158    #[test]
3159    fn error_reason_sql_exec_insufficient_privileges() {
3160        let err = ReconcileError::SqlExec(insufficient_privilege_sqlx_error());
3161        assert_eq!(err.reason(), "InsufficientPrivileges");
3162    }
3163
3164    #[test]
3165    fn error_reason_inspect_insufficient_privileges() {
3166        let err = ReconcileError::Inspect(pgroles_inspect::InspectError::Database(
3167            insufficient_privilege_sqlx_error(),
3168        ));
3169        assert_eq!(err.reason(), "InsufficientPrivileges");
3170    }
3171
3172    #[test]
3173    fn error_display_includes_details() {
3174        let err = ReconcileError::InvalidInterval("5x".into(), "unknown unit 'x'".into());
3175        let msg = err.to_string();
3176        assert!(msg.contains("5x"), "error display should contain interval");
3177        assert!(
3178            msg.contains("unknown unit"),
3179            "error display should contain reason"
3180        );
3181    }
3182
3183    #[test]
3184    fn error_reason_lock_contention() {
3185        let err = ReconcileError::LockContention(
3186            "prod/db-creds/DATABASE_URL".into(),
3187            "in-process lock held".into(),
3188        );
3189        assert_eq!(err.reason(), "LockContention");
3190    }
3191
3192    #[test]
3193    fn error_display_lock_contention_includes_database() {
3194        let err = ReconcileError::LockContention(
3195            "prod/db-creds/DATABASE_URL".into(),
3196            "advisory lock held by another session".into(),
3197        );
3198        let msg = err.to_string();
3199        assert!(
3200            msg.contains("prod/db-creds/DATABASE_URL"),
3201            "lock contention error should include database identity"
3202        );
3203        assert!(
3204            msg.contains("advisory lock"),
3205            "lock contention error should include reason"
3206        );
3207    }
3208
3209    #[test]
3210    fn requeue_with_jitter_produces_bounded_delay() {
3211        // Run multiple times to exercise the jitter distribution.
3212        let base = LOCK_CONTENTION_BASE_SECS;
3213        let max = LOCK_CONTENTION_BASE_SECS + LOCK_CONTENTION_JITTER_SECS;
3214        for _ in 0..20 {
3215            let delay = jitter_delay();
3216            let secs = delay.as_secs();
3217            assert!(
3218                secs >= base,
3219                "jitter delay {secs}s should be at least base {base}s",
3220            );
3221            assert!(
3222                secs <= max,
3223                "jitter delay {secs}s should not exceed base+jitter {max}s",
3224            );
3225        }
3226    }
3227
3228    #[test]
3229    fn lock_contention_constants_are_reasonable() {
3230        // Use variables to avoid clippy::assertions_on_constants.
3231        let base = LOCK_CONTENTION_BASE_SECS;
3232        let jitter = LOCK_CONTENTION_JITTER_SECS;
3233        assert!(base > 0, "base delay must be positive");
3234        assert!(jitter > 0, "jitter window must be positive");
3235        assert!(
3236            base + jitter <= 60,
3237            "total max contention delay should not exceed error_policy's 60s"
3238        );
3239    }
3240
3241    #[test]
3242    fn transient_backoff_delay_is_bounded_and_caps() {
3243        for _ in 0..20 {
3244            let first = transient_backoff_delay(1).as_secs();
3245            assert!((TRANSIENT_BACKOFF_BASE_SECS..=7).contains(&first));
3246
3247            let fourth = transient_backoff_delay(4).as_secs();
3248            assert!((40..=60).contains(&fourth));
3249
3250            let capped = transient_backoff_delay(10).as_secs();
3251            assert_eq!(capped, TRANSIENT_BACKOFF_MAX_SECS);
3252        }
3253    }
3254
3255    #[test]
3256    fn slow_retry_delay_uses_policy_interval() {
3257        let resource = test_policy("7m", 0);
3258        assert_eq!(slow_retry_delay(&resource), Duration::from_secs(420));
3259    }
3260
3261    #[test]
3262    fn slow_retry_delay_falls_back_on_invalid_interval() {
3263        let resource = test_policy("nope", 0);
3264        assert_eq!(
3265            slow_retry_delay(&resource),
3266            Duration::from_secs(DEFAULT_REQUEUE_SECS)
3267        );
3268    }
3269
3270    #[test]
3271    fn retry_classifies_lock_contention_separately() {
3272        let error = finalizer::Error::ApplyFailed(ReconcileError::LockContention(
3273            "default/db-credentials/DATABASE_URL".into(),
3274            "lock held".into(),
3275        ));
3276        assert_eq!(retry_class(&error), RetryClass::LockContention);
3277    }
3278
3279    #[test]
3280    fn retry_classifies_invalid_spec_as_slow() {
3281        let error = finalizer::Error::ApplyFailed(ReconcileError::InvalidInterval(
3282            "oops".into(),
3283            "bad interval".into(),
3284        ));
3285        assert_eq!(retry_class(&error), RetryClass::Slow);
3286    }
3287
3288    #[test]
3289    fn retry_classifies_missing_database_objects_as_slow() {
3290        let error = finalizer::Error::ApplyFailed(ReconcileError::MissingDatabaseObjects(
3291            "schema \"etl\"".into(),
3292        ));
3293        assert_eq!(retry_class(&error), RetryClass::Slow);
3294    }
3295
3296    #[test]
3297    fn retry_classifies_unsatisfiable_wildcard_grant_as_slow() {
3298        let error = finalizer::Error::ApplyFailed(ReconcileError::UnsatisfiableWildcardGrant(
3299            "UnsatisfiableWildcardGrant: function f2() is not grantable".into(),
3300        ));
3301        assert_eq!(retry_class(&error), RetryClass::Slow);
3302    }
3303
3304    #[test]
3305    fn retry_classifies_plan_sql_storage_as_slow() {
3306        let error =
3307            finalizer::Error::ApplyFailed(ReconcileError::PlanSqlStorage("gzip failed".into()));
3308        assert_eq!(retry_class(&error), RetryClass::Slow);
3309    }
3310
3311    #[test]
3312    fn retry_classifies_secret_missing_as_slow() {
3313        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3314            crate::context::ContextError::SecretMissing {
3315                name: "db-credentials".into(),
3316                key: "DATABASE_URL".into(),
3317            },
3318        )));
3319        assert_eq!(retry_class(&error), RetryClass::Slow);
3320    }
3321
3322    #[test]
3323    fn retry_classifies_secret_fetch_not_found_as_slow() {
3324        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3325            crate::context::ContextError::SecretFetch {
3326                name: "db-credentials".into(),
3327                namespace: "default".into(),
3328                source: kube::Error::Api(
3329                    kube::core::Status::failure("secrets \"db-credentials\" not found", "NotFound")
3330                        .with_code(404)
3331                        .boxed(),
3332                ),
3333            },
3334        )));
3335        assert_eq!(retry_class(&error), RetryClass::Slow);
3336    }
3337
3338    #[test]
3339    fn retry_classifies_secret_fetch_transport_errors_as_transient() {
3340        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3341            crate::context::ContextError::SecretFetch {
3342                name: "db-credentials".into(),
3343                namespace: "default".into(),
3344                source: kube::Error::Api(
3345                    kube::core::Status::failure("internal error", "InternalError")
3346                        .with_code(500)
3347                        .boxed(),
3348                ),
3349            },
3350        )));
3351        assert_eq!(retry_class(&error), RetryClass::Transient);
3352    }
3353
3354    #[test]
3355    fn retry_classifies_secret_fetch_forbidden_as_slow() {
3356        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3357            crate::context::ContextError::SecretFetch {
3358                name: "db-credentials".into(),
3359                namespace: "default".into(),
3360                source: kube::Error::Api(
3361                    kube::core::Status::failure("forbidden", "Forbidden")
3362                        .with_code(403)
3363                        .boxed(),
3364                ),
3365            },
3366        )));
3367        assert_eq!(retry_class(&error), RetryClass::Slow);
3368    }
3369
3370    #[test]
3371    fn retry_classifies_database_connect_as_transient() {
3372        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3373            crate::context::ContextError::DatabaseConnect {
3374                source: sqlx::Error::PoolTimedOut,
3375            },
3376        )));
3377        assert_eq!(retry_class(&error), RetryClass::Transient);
3378    }
3379
3380    #[test]
3381    fn retry_classifies_set_role_failed_as_slow() {
3382        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3383            crate::context::ContextError::SetRoleFailed {
3384                role: "cloudsqlsuperuser".to_string(),
3385                source: sqlx::Error::Protocol("permission denied".to_string()),
3386            },
3387        )));
3388        assert_eq!(retry_class(&error), RetryClass::Slow);
3389    }
3390
3391    #[test]
3392    fn retry_classifies_sql_exec_insufficient_privilege_as_slow() {
3393        let error = finalizer::Error::ApplyFailed(ReconcileError::SqlExec(
3394            insufficient_privilege_sqlx_error(),
3395        ));
3396        assert_eq!(retry_class(&error), RetryClass::Slow);
3397    }
3398
3399    #[test]
3400    fn retry_classifies_inspect_insufficient_privilege_as_slow() {
3401        let error = finalizer::Error::ApplyFailed(ReconcileError::Inspect(
3402            pgroles_inspect::InspectError::Database(insufficient_privilege_sqlx_error()),
3403        ));
3404        assert_eq!(retry_class(&error), RetryClass::Slow);
3405    }
3406
3407    #[test]
3408    fn classify_sqlx_error_categories() {
3409        assert_eq!(
3410            classify_sqlx_error(&insufficient_privilege_sqlx_error()),
3411            SqlErrorKind::InsufficientPrivileges
3412        );
3413        assert_eq!(
3414            classify_sqlx_error(&missing_schema_sqlx_error()),
3415            SqlErrorKind::MissingDatabaseObject
3416        );
3417        assert_eq!(
3418            classify_sqlx_error(&missing_table_sqlx_error()),
3419            SqlErrorKind::MissingDatabaseObject
3420        );
3421        assert_eq!(
3422            classify_sqlx_error(&missing_function_sqlx_error()),
3423            SqlErrorKind::MissingDatabaseObject
3424        );
3425        assert_eq!(
3426            classify_sqlx_error(&missing_object_sqlx_error()),
3427            SqlErrorKind::MissingDatabaseObject
3428        );
3429        assert_eq!(
3430            classify_sqlx_error(&transient_sqlx_error()),
3431            SqlErrorKind::Transient
3432        );
3433    }
3434
3435    #[test]
3436    fn retry_classifies_sql_exec_missing_schema_as_slow() {
3437        let error =
3438            finalizer::Error::ApplyFailed(ReconcileError::SqlExec(missing_schema_sqlx_error()));
3439        assert_eq!(retry_class(&error), RetryClass::Slow);
3440    }
3441
3442    #[test]
3443    fn retry_classifies_sql_exec_missing_table_as_slow() {
3444        let error =
3445            finalizer::Error::ApplyFailed(ReconcileError::SqlExec(missing_table_sqlx_error()));
3446        assert_eq!(retry_class(&error), RetryClass::Slow);
3447    }
3448
3449    #[test]
3450    fn retry_classifies_inspect_missing_schema_as_slow() {
3451        let error = finalizer::Error::ApplyFailed(ReconcileError::Inspect(
3452            pgroles_inspect::InspectError::Database(missing_schema_sqlx_error()),
3453        ));
3454        assert_eq!(retry_class(&error), RetryClass::Slow);
3455    }
3456
3457    #[test]
3458    fn error_reason_sql_exec_missing_database_object() {
3459        let err = ReconcileError::SqlExec(missing_schema_sqlx_error());
3460        assert_eq!(err.reason(), "MissingDatabaseObject");
3461    }
3462
3463    #[test]
3464    fn error_reason_inspect_missing_database_object() {
3465        let err = ReconcileError::Inspect(pgroles_inspect::InspectError::Database(
3466            missing_table_sqlx_error(),
3467        ));
3468        assert_eq!(err.reason(), "MissingDatabaseObject");
3469    }
3470
3471    #[test]
3472    fn retry_classifies_empty_resolved_value_as_slow() {
3473        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3474            crate::context::ContextError::EmptyResolvedValue {
3475                field: "password".to_string(),
3476            },
3477        )));
3478        assert_eq!(retry_class(&error), RetryClass::Slow);
3479    }
3480
3481    #[test]
3482    fn error_reason_empty_resolved_value() {
3483        let err =
3484            ReconcileError::Context(Box::new(crate::context::ContextError::EmptyResolvedValue {
3485                field: "host".to_string(),
3486            }));
3487        assert_eq!(err.reason(), "InvalidConnectionParams");
3488    }
3489
3490    #[test]
3491    fn retry_classifies_invalid_resolved_ssl_mode_as_slow() {
3492        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3493            crate::context::ContextError::InvalidResolvedSslMode {
3494                value: "bogus".to_string(),
3495            },
3496        )));
3497        assert_eq!(retry_class(&error), RetryClass::Slow);
3498    }
3499
3500    #[test]
3501    fn error_reason_invalid_resolved_ssl_mode() {
3502        let err = ReconcileError::Context(Box::new(
3503            crate::context::ContextError::InvalidResolvedSslMode {
3504                value: "bogus".to_string(),
3505            },
3506        ));
3507        assert_eq!(err.reason(), "InvalidConnectionParams");
3508    }
3509
3510    #[test]
3511    fn retry_classifies_gcp_auth_permission_error_as_slow() {
3512        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3513            crate::context::ContextError::GcpAuthRejected {
3514                endpoint: "metadata".to_string(),
3515                status: 403,
3516                body: "forbidden".to_string(),
3517            },
3518        )));
3519        assert_eq!(retry_class(&error), RetryClass::Slow);
3520    }
3521
3522    #[tokio::test]
3523    async fn retry_classifies_gcp_auth_http_error_as_transient() {
3524        let source = reqwest::Client::new()
3525            .get("http://")
3526            .send()
3527            .await
3528            .expect_err("invalid URL should produce a reqwest error");
3529        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3530            crate::context::ContextError::GcpAuthHttp {
3531                endpoint: "metadata",
3532                source,
3533            },
3534        )));
3535        assert_eq!(retry_class(&error), RetryClass::Transient);
3536    }
3537
3538    #[test]
3539    fn error_reason_gcp_auth_failure() {
3540        let err =
3541            ReconcileError::Context(Box::new(crate::context::ContextError::GcpAuthRejected {
3542                endpoint: "metadata".to_string(),
3543                status: 403,
3544                body: "forbidden".to_string(),
3545            }));
3546        assert_eq!(err.reason(), "GcpAuthFailed");
3547    }
3548
3549    #[test]
3550    fn error_reason_sql_exec_transient_is_apply_failed() {
3551        let err = ReconcileError::SqlExec(transient_sqlx_error());
3552        assert_eq!(err.reason(), "ApplyFailed");
3553    }
3554
3555    #[test]
3556    fn error_reason_plan_sql_storage_failed() {
3557        let err = ReconcileError::PlanSqlStorage("gzip failed".into());
3558        assert_eq!(err.reason(), "PlanSqlStorageFailed");
3559    }
3560
3561    #[test]
3562    fn error_policy_uses_normal_interval_for_invalid_spec() {
3563        let resource = test_policy("11m", 0);
3564        let error = finalizer::Error::ApplyFailed(ReconcileError::InvalidInterval(
3565            "oops".into(),
3566            "bad interval".into(),
3567        ));
3568        assert_eq!(
3569            retry_action(&resource, &error),
3570            Action::requeue(Duration::from_secs(660))
3571        );
3572    }
3573
3574    #[test]
3575    fn error_policy_uses_exponential_backoff_for_transient_failures() {
3576        let resource = test_policy("5m", 3);
3577        let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3578            crate::context::ContextError::DatabaseConnect {
3579                source: sqlx::Error::PoolTimedOut,
3580            },
3581        )));
3582        let action = retry_action(&resource, &error);
3583        assert!(
3584            (40..=60).any(|secs| action == Action::requeue(Duration::from_secs(secs))),
3585            "expected transient retry between 40s and 60s, got {action:?}"
3586        );
3587    }
3588
3589    #[test]
3590    fn render_plan_sql_for_status_redacts_passwords() {
3591        let changes = vec![
3592            pgroles_core::diff::Change::CreateRole {
3593                name: "app-svc".to_string(),
3594                state: pgroles_core::model::RoleState {
3595                    login: true,
3596                    ..pgroles_core::model::RoleState::default()
3597                },
3598            },
3599            pgroles_core::diff::Change::SetPassword {
3600                name: "app-svc".to_string(),
3601                password: "super_secret_p@ssw0rd!".to_string(),
3602            },
3603        ];
3604
3605        let sql_ctx = pgroles_core::sql::SqlContext::default();
3606        let (sql, truncated) = render_plan_sql_for_status(&changes, &sql_ctx);
3607
3608        let sql = sql.expect("expected non-empty planned SQL");
3609        assert!(!truncated);
3610        assert!(
3611            sql.contains("[REDACTED]"),
3612            "status SQL should contain [REDACTED], got: {sql}"
3613        );
3614        assert!(
3615            !sql.contains("super_secret_p@ssw0rd!"),
3616            "status SQL must NOT contain the actual password, got: {sql}"
3617        );
3618        assert!(
3619            sql.contains("CREATE ROLE"),
3620            "status SQL should still contain non-password changes, got: {sql}"
3621        );
3622    }
3623
3624    #[test]
3625    fn render_plan_sql_for_status_empty_changes_returns_none() {
3626        let sql_ctx = pgroles_core::sql::SqlContext::default();
3627        let (sql, truncated) = render_plan_sql_for_status(&[], &sql_ctx);
3628        assert!(sql.is_none());
3629        assert!(!truncated);
3630    }
3631
3632    #[test]
3633    fn render_plan_sql_for_status_password_only_plan() {
3634        let changes = vec![pgroles_core::diff::Change::SetPassword {
3635            name: "db-user".to_string(),
3636            password: "my_secret_pw".to_string(),
3637        }];
3638
3639        let sql_ctx = pgroles_core::sql::SqlContext::default();
3640        let (sql, _) = render_plan_sql_for_status(&changes, &sql_ctx);
3641
3642        let sql = sql.expect("expected non-empty planned SQL");
3643        assert!(
3644            sql.contains("[REDACTED]"),
3645            "password-only plan should still show redacted SQL"
3646        );
3647        assert!(
3648            !sql.contains("my_secret_pw"),
3649            "password-only plan must NOT leak the password"
3650        );
3651    }
3652
3653    #[test]
3654    fn error_reason_empty_password_secret() {
3655        let err = ReconcileError::EmptyPasswordSecret {
3656            role: "app-svc".to_string(),
3657            secret: "pg-passwords".to_string(),
3658            key: "app-svc".to_string(),
3659        };
3660        assert_eq!(err.reason(), "InvalidSpec");
3661    }
3662
3663    #[test]
3664    fn retry_classifies_empty_password_secret_as_slow() {
3665        let error = finalizer::Error::ApplyFailed(ReconcileError::EmptyPasswordSecret {
3666            role: "app-svc".to_string(),
3667            secret: "pg-passwords".to_string(),
3668            key: "app-svc".to_string(),
3669        });
3670        assert_eq!(retry_class(&error), RetryClass::Slow);
3671    }
3672
3673    #[test]
3674    fn error_reason_password_generation() {
3675        let err = ReconcileError::PasswordGeneration(Box::new(
3676            crate::password::PasswordError::MissingKey {
3677                secret: "my-secret".to_string(),
3678                key: "password".to_string(),
3679            },
3680        ));
3681        assert_eq!(err.reason(), "SecretFetchFailed");
3682    }
3683
3684    #[test]
3685    fn retry_classifies_password_generation_missing_key_as_slow() {
3686        let error = finalizer::Error::ApplyFailed(ReconcileError::PasswordGeneration(Box::new(
3687            crate::password::PasswordError::MissingKey {
3688                secret: "my-secret".to_string(),
3689                key: "password".to_string(),
3690            },
3691        )));
3692        assert_eq!(retry_class(&error), RetryClass::Slow);
3693    }
3694
3695    #[test]
3696    fn retry_classifies_password_generation_kube_server_error_as_transient() {
3697        let error = finalizer::Error::ApplyFailed(ReconcileError::PasswordGeneration(Box::new(
3698            crate::password::PasswordError::KubeApi {
3699                secret: "my-secret".to_string(),
3700                source: Box::new(kube::Error::Api(
3701                    kube::core::Status::failure("internal error", "InternalError")
3702                        .with_code(500)
3703                        .boxed(),
3704                )),
3705            },
3706        )));
3707        assert_eq!(retry_class(&error), RetryClass::Transient);
3708    }
3709
3710    #[test]
3711    fn retry_classifies_password_generation_kube_forbidden_as_slow() {
3712        let error = finalizer::Error::ApplyFailed(ReconcileError::PasswordGeneration(Box::new(
3713            crate::password::PasswordError::KubeApi {
3714                secret: "my-secret".to_string(),
3715                source: Box::new(kube::Error::Api(
3716                    kube::core::Status::failure("forbidden", "Forbidden")
3717                        .with_code(403)
3718                        .boxed(),
3719                )),
3720            },
3721        )));
3722        assert_eq!(retry_class(&error), RetryClass::Slow);
3723    }
3724
3725    #[test]
3726    fn accumulate_summary_counts_passwords() {
3727        use pgroles_core::diff::Change;
3728
3729        let mut summary = ChangeSummary::default();
3730        accumulate_summary(
3731            &mut summary,
3732            &Change::SetPassword {
3733                name: "app-svc".to_string(),
3734                password: "secret".to_string(),
3735            },
3736        );
3737        assert_eq!(summary.passwords_set, 1);
3738    }
3739
3740    #[test]
3741    fn conflict_detection_ignores_invalid_peer_policies() {
3742        let resource = valid_role_policy("valid-policy", "analytics", "shared-db-secret");
3743        let identity = DatabaseIdentity::from_connection("default", &resource.spec.connection);
3744        let ownership = resource.spec.ownership_claims().unwrap();
3745        let invalid_peer = invalid_profile_policy("invalid-peer", "shared-db-secret");
3746
3747        let conflict =
3748            detect_policy_conflict_in_list(&resource, &identity, &ownership, vec![invalid_peer]);
3749
3750        assert_eq!(conflict, None);
3751    }
3752
3753    #[test]
3754    fn resolve_passwords_from_cached_secrets_supports_default_and_explicit_keys() {
3755        let resource = password_role_policy();
3756        let cache = BTreeMap::from([(
3757            "role-passwords".to_string(),
3758            secret_with_keys(
3759                "role-passwords",
3760                &[
3761                    ("app", "app-secret"),
3762                    ("reporter-password", "reporter-secret"),
3763                ],
3764            ),
3765        )]);
3766
3767        let resolved =
3768            resolve_passwords_from_cached_secrets(&resource, &cache).expect("should resolve");
3769
3770        assert_eq!(
3771            resolved
3772                .get("app")
3773                .map(|password| password.cleartext.as_str()),
3774            Some("app-secret")
3775        );
3776        assert_eq!(
3777            resolved
3778                .get("reporter")
3779                .map(|password| password.cleartext.as_str()),
3780            Some("reporter-secret")
3781        );
3782    }
3783
3784    #[test]
3785    fn resolve_passwords_from_cached_secrets_reports_missing_key() {
3786        let resource = password_role_policy();
3787        let cache = BTreeMap::from([(
3788            "role-passwords".to_string(),
3789            secret_with_keys("role-passwords", &[("app", "app-secret")]),
3790        )]);
3791
3792        let err = resolve_passwords_from_cached_secrets(&resource, &cache).unwrap_err();
3793        let context = match err {
3794            ReconcileError::Context(context) => context,
3795            other => panic!("expected context error, got {other:?}"),
3796        };
3797        assert!(matches!(
3798            *context,
3799            crate::context::ContextError::SecretMissing { ref name, ref key }
3800            if name == "role-passwords" && key == "reporter-password"
3801        ));
3802    }
3803
3804    #[test]
3805    fn resolve_passwords_from_cached_secrets_reports_empty_password() {
3806        let resource = password_role_policy();
3807        let cache = BTreeMap::from([(
3808            "role-passwords".to_string(),
3809            secret_with_keys(
3810                "role-passwords",
3811                &[("app", ""), ("reporter-password", "ok")],
3812            ),
3813        )]);
3814
3815        let err = resolve_passwords_from_cached_secrets(&resource, &cache).unwrap_err();
3816        assert!(matches!(
3817            err,
3818            ReconcileError::EmptyPasswordSecret { ref role, ref secret, ref key }
3819            if role == "app" && secret == "role-passwords" && key == "app"
3820        ));
3821    }
3822
3823    #[test]
3824    fn resolve_passwords_from_cached_secrets_allows_whitespace_passwords() {
3825        let resource = password_role_policy();
3826        let cache = BTreeMap::from([(
3827            "role-passwords".to_string(),
3828            secret_with_keys(
3829                "role-passwords",
3830                &[("app", "   "), ("reporter-password", "\tsecret")],
3831            ),
3832        )]);
3833
3834        let resolved =
3835            resolve_passwords_from_cached_secrets(&resource, &cache).expect("should resolve");
3836
3837        assert_eq!(
3838            resolved
3839                .get("app")
3840                .map(|password| password.cleartext.as_str()),
3841            Some("   ")
3842        );
3843        assert_eq!(
3844            resolved
3845                .get("reporter")
3846                .map(|password| password.cleartext.as_str()),
3847            Some("\tsecret")
3848        );
3849    }
3850
3851    #[test]
3852    fn select_password_changes_skips_unchanged_password_sources() {
3853        let resolved = BTreeMap::from([(
3854            "app".to_string(),
3855            ResolvedPassword {
3856                cleartext: "app-secret".to_string(),
3857                source_version: "role-passwords:app:7".to_string(),
3858            },
3859        )]);
3860        let status = PostgresPolicyStatus {
3861            applied_password_source_versions: BTreeMap::from([(
3862                "app".to_string(),
3863                "role-passwords:app:7".to_string(),
3864            )]),
3865            ..Default::default()
3866        };
3867
3868        let (password_changes, current_versions) =
3869            select_password_changes(&[], &resolved, Some(&status));
3870
3871        assert!(password_changes.is_empty());
3872        assert_eq!(
3873            current_versions.get("app").map(String::as_str),
3874            Some("role-passwords:app:7")
3875        );
3876    }
3877
3878    #[test]
3879    fn select_password_changes_applies_on_source_version_change() {
3880        let resolved = BTreeMap::from([(
3881            "app".to_string(),
3882            ResolvedPassword {
3883                cleartext: "new-secret".to_string(),
3884                source_version: "role-passwords:app:8".to_string(),
3885            },
3886        )]);
3887        let status = PostgresPolicyStatus {
3888            applied_password_source_versions: BTreeMap::from([(
3889                "app".to_string(),
3890                "role-passwords:app:7".to_string(),
3891            )]),
3892            ..Default::default()
3893        };
3894
3895        let (password_changes, _) = select_password_changes(&[], &resolved, Some(&status));
3896
3897        assert_eq!(
3898            password_changes.get("app").map(String::as_str),
3899            Some("new-secret")
3900        );
3901    }
3902
3903    #[test]
3904    fn select_password_changes_applies_for_newly_created_role() {
3905        use pgroles_core::diff::Change;
3906        use pgroles_core::model::RoleState;
3907
3908        let resolved = BTreeMap::from([(
3909            "app".to_string(),
3910            ResolvedPassword {
3911                cleartext: "new-secret".to_string(),
3912                source_version: "role-passwords:app:7".to_string(),
3913            },
3914        )]);
3915        let status = PostgresPolicyStatus {
3916            applied_password_source_versions: BTreeMap::from([(
3917                "app".to_string(),
3918                "role-passwords:app:7".to_string(),
3919            )]),
3920            ..Default::default()
3921        };
3922        let changes = vec![Change::CreateRole {
3923            name: "app".to_string(),
3924            state: RoleState {
3925                login: true,
3926                ..RoleState::default()
3927            },
3928        }];
3929
3930        let (password_changes, _) = select_password_changes(&changes, &resolved, Some(&status));
3931
3932        assert_eq!(
3933            password_changes.get("app").map(String::as_str),
3934            Some("new-secret")
3935        );
3936    }
3937
3938    #[test]
3939    fn select_password_changes_applies_all_on_first_reconcile() {
3940        // When status is None (first reconcile), all passwords should be applied
3941        // since there are no previous source versions to compare against.
3942        let resolved = BTreeMap::from([
3943            (
3944                "app".to_string(),
3945                ResolvedPassword {
3946                    cleartext: "secret-a".to_string(),
3947                    source_version: "role-passwords:app:1".to_string(),
3948                },
3949            ),
3950            (
3951                "reporter".to_string(),
3952                ResolvedPassword {
3953                    cleartext: "secret-b".to_string(),
3954                    source_version: "role-passwords:reporter:1".to_string(),
3955                },
3956            ),
3957        ]);
3958        let changes: Vec<pgroles_core::diff::Change> = vec![];
3959
3960        let (password_changes, versions) = select_password_changes(&changes, &resolved, None);
3961
3962        assert_eq!(
3963            password_changes.len(),
3964            2,
3965            "all passwords should be applied on first reconcile"
3966        );
3967        assert_eq!(
3968            password_changes.get("app").map(String::as_str),
3969            Some("secret-a")
3970        );
3971        assert_eq!(
3972            password_changes.get("reporter").map(String::as_str),
3973            Some("secret-b")
3974        );
3975        assert_eq!(versions.len(), 2, "all source versions should be tracked");
3976    }
3977
3978    #[test]
3979    fn conflict_detection_still_reports_overlapping_valid_peers() {
3980        let resource = valid_role_policy("valid-policy", "analytics", "shared-db-secret");
3981        let identity = DatabaseIdentity::from_connection("default", &resource.spec.connection);
3982        let ownership = resource.spec.ownership_claims().unwrap();
3983        let overlapping_peer =
3984            valid_role_policy("overlapping-peer", "analytics", "shared-db-secret");
3985        let invalid_peer = invalid_profile_policy("invalid-peer", "shared-db-secret");
3986
3987        let conflict = detect_policy_conflict_in_list(
3988            &resource,
3989            &identity,
3990            &ownership,
3991            vec![invalid_peer, overlapping_peer],
3992        );
3993
3994        let conflict = conflict.expect("expected overlapping peer to be reported");
3995        assert!(conflict.contains("overlapping-peer"));
3996        assert!(conflict.contains("roles: analytics"));
3997    }
3998
3999    #[test]
4000    fn parse_rfc3339_to_epoch_secs_known_timestamp() {
4001        // 2024-01-01T00:00:00Z = 1704067200
4002        let result = parse_rfc3339_to_epoch_secs("2024-01-01T00:00:00Z");
4003        assert_eq!(result, Some(1704067200));
4004    }
4005
4006    #[test]
4007    fn parse_rfc3339_to_epoch_secs_with_time() {
4008        // 2024-01-01T12:30:45Z = 1704067200 + 12*3600 + 30*60 + 45 = 1704112245
4009        let result = parse_rfc3339_to_epoch_secs("2024-01-01T12:30:45Z");
4010        assert_eq!(result, Some(1704112245));
4011    }
4012
4013    #[test]
4014    fn parse_rfc3339_to_epoch_secs_invalid_returns_none() {
4015        assert_eq!(parse_rfc3339_to_epoch_secs("not-a-date"), None);
4016        assert_eq!(parse_rfc3339_to_epoch_secs(""), None);
4017    }
4018
4019    #[test]
4020    fn parse_rfc3339_roundtrips_with_now_rfc3339() {
4021        let timestamp = crate::crd::now_rfc3339();
4022        let parsed = parse_rfc3339_to_epoch_secs(&timestamp);
4023        assert!(parsed.is_some(), "should parse our own timestamps");
4024        let now_secs = std::time::SystemTime::now()
4025            .duration_since(std::time::UNIX_EPOCH)
4026            .unwrap()
4027            .as_secs();
4028        // Should be within 2 seconds of now.
4029        let diff = now_secs.abs_diff(parsed.unwrap());
4030        assert!(diff <= 2, "parsed time should be close to now, diff={diff}");
4031    }
4032}