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