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