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