Skip to main content

pgroles_operator/
reconciler.rs

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