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