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 !inspection.diagnostics.is_empty() {
937 return Err(ReconcileError::UnsatisfiableWildcardGrant(
938 inspection.diagnostics.to_string(),
939 ));
940 }
941 let current = inspection.graph;
942
943 validate_referenced_schemas_exist(pool, expanded).await?;
948
949 let reconciliation_mode: pgroles_core::diff::ReconciliationMode =
952 resource.spec.reconciliation_mode.into();
953 tracing::info!(%reconciliation_mode, "reconciliation mode");
954 let mut changes = pgroles_core::diff::filter_changes(
955 pgroles_core::diff::apply_role_retirements(
956 pgroles_core::diff::diff(¤t, desired),
957 &manifest.retirements,
958 ),
959 reconciliation_mode,
960 );
961 changes = pgroles_core::diff::filter_external_role_changes(changes, &expanded.roles);
962
963 let resolved_passwords = resolve_passwords_from_secrets(ctx, resource, namespace).await?;
964 let (password_changes, applied_password_source_versions) =
965 select_password_changes(&changes, &resolved_passwords, resource.status.as_ref());
966 if !password_changes.is_empty() {
967 changes = pgroles_core::diff::inject_password_changes(changes, &password_changes);
968 }
969 let dropped_roles: Vec<String> = changes
970 .iter()
971 .filter_map(|change| match change {
972 pgroles_core::diff::Change::DropRole { name } => Some(name.clone()),
973 _ => None,
974 })
975 .collect();
976 let drop_safety = pgroles_inspect::inspect_drop_role_safety(pool, &dropped_roles)
977 .await?
978 .assess(&manifest.retirements);
979 if !drop_safety.warnings.is_empty() {
980 tracing::info!(warnings = %drop_safety.warnings, "role-drop cleanup warnings");
981 }
982 if drop_safety.has_blockers() {
983 return Err(ReconcileError::UnsafeRoleDrops(
984 drop_safety.blockers.to_string(),
985 ));
986 }
987
988 let summary = summarize_changes(&changes);
989 let sql_ctx = detect_sql_context(pool, &inspect_config).await?;
990 let (planned_sql, planned_sql_truncated) = render_plan_sql_for_status(&changes, &sql_ctx);
991
992 let effective_approval = resource.spec.effective_approval();
993
994 if resource.spec.mode == PolicyMode::Plan {
995 let drift_detected = !changes.is_empty();
996 let ready_message = if drift_detected {
997 format!("Plan computed; {} change(s) pending", summary.total)
998 } else {
999 "Plan computed; database already matches desired state".to_string()
1000 };
1001 let drift_reason = if drift_detected {
1002 "DriftDetected"
1003 } else {
1004 "InSync"
1005 };
1006 let drift_message = if drift_detected {
1007 format!("{} planned change(s) pending review", summary.total)
1008 } else {
1009 "No pending changes".to_string()
1010 };
1011
1012 ctx.observability
1013 .record_plan_result(if drift_detected { "drift" } else { "clean" });
1014 ctx.observability
1015 .record_planned_changes(summary.total.max(0) as usize);
1016
1017 let mut plan_ref_name = None;
1019 if drift_detected {
1020 let creation_result = crate::plan::create_or_update_plan(
1021 &ctx.kube_client,
1022 resource,
1023 &changes,
1024 &sql_ctx,
1025 &inspect_config,
1026 resource.spec.reconciliation_mode,
1027 identity.as_str(),
1028 &summary,
1029 )
1030 .await?;
1031 let plan_name = creation_result.plan_name().to_string();
1032
1033 if creation_result.is_created() {
1035 let plans_api: Api<PostgresPolicyPlan> =
1036 Api::namespaced(ctx.kube_client.clone(), namespace);
1037 let created_plan = plans_api.get(&plan_name).await?;
1038 emit_plan_event(
1039 ctx,
1040 resource,
1041 &created_plan,
1042 PlanEventType::Created {
1043 change_count: summary.total,
1044 },
1045 )
1046 .await;
1047 }
1048
1049 crate::plan::update_policy_plan_ref(&ctx.kube_client, resource, &plan_name).await?;
1050
1051 plan_ref_name = Some(plan_name);
1052 }
1053
1054 update_status(ctx, resource, |status| {
1056 status.set_condition(ready_condition(true, "Planned", &ready_message));
1057 status.set_condition(drifted_condition(
1058 drift_detected,
1059 drift_reason,
1060 &drift_message,
1061 ));
1062 status.conditions.retain(|c| {
1063 c.condition_type != "Reconciling"
1064 && c.condition_type != "Degraded"
1065 && c.condition_type != "Conflict"
1066 && c.condition_type != "Paused"
1067 });
1068 status.observed_generation = generation;
1069 status.last_attempted_generation = generation;
1070 status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1071 status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1072 status.change_summary = Some(summary.clone());
1073 status.last_reconcile_mode = Some(PolicyMode::Plan);
1074 status.planned_sql = planned_sql.clone();
1075 status.planned_sql_truncated = planned_sql_truncated;
1076 status.last_error = None;
1077 status.transient_failure_count = 0;
1078 if let Some(ref plan_name) = plan_ref_name {
1079 status.current_plan_ref = Some(crate::crd::PlanReference {
1080 name: plan_name.clone(),
1081 });
1082 }
1083 })
1084 .await?;
1085
1086 info!(
1087 name,
1088 namespace,
1089 total = summary.total,
1090 drift_detected,
1091 "plan reconciliation complete"
1092 );
1093 return Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned));
1094 }
1095
1096 match effective_approval {
1098 crate::crd::ApprovalMode::Auto => {
1099 if !changes.is_empty() {
1102 let creation_result = crate::plan::create_or_update_plan(
1103 &ctx.kube_client,
1104 resource,
1105 &changes,
1106 &sql_ctx,
1107 &inspect_config,
1108 resource.spec.reconciliation_mode,
1109 identity.as_str(),
1110 &summary,
1111 )
1112 .await?;
1113 let plan_name = creation_result.plan_name().to_string();
1114
1115 let plans_api: Api<PostgresPolicyPlan> =
1117 Api::namespaced(ctx.kube_client.clone(), namespace);
1118 let plan = plans_api.get(&plan_name).await?;
1119
1120 if creation_result.is_created() {
1121 emit_plan_event(
1122 ctx,
1123 resource,
1124 &plan,
1125 PlanEventType::Created {
1126 change_count: summary.total,
1127 },
1128 )
1129 .await;
1130 }
1131
1132 crate::plan::mark_plan_approved(
1133 &ctx.kube_client,
1134 &plan,
1135 "AutoApproved",
1136 "Plan auto-approved by policy approval mode",
1137 )
1138 .await?;
1139
1140 let plan = plans_api.get(&plan_name).await?;
1142 emit_plan_event(ctx, resource, &plan, PlanEventType::Approved).await;
1143 emit_plan_event(ctx, resource, &plan, PlanEventType::ApplyStarted).await;
1144
1145 match crate::plan::execute_plan(&ctx.kube_client, &plan, pool, &sql_ctx, &changes)
1146 .await
1147 {
1148 Ok(()) => {
1149 emit_plan_event(ctx, resource, &plan, PlanEventType::ApplySucceeded).await;
1150 }
1151 Err(err) => {
1152 emit_plan_event(
1153 ctx,
1154 resource,
1155 &plan,
1156 PlanEventType::ApplyFailed {
1157 error: err.to_string(),
1158 },
1159 )
1160 .await;
1161 return Err(err);
1162 }
1163 }
1164
1165 ctx.observability.record_apply_result("success");
1166
1167 crate::plan::update_policy_plan_ref(&ctx.kube_client, resource, &plan_name).await?;
1168
1169 info!(
1170 name,
1171 namespace,
1172 total = summary.total,
1173 plan = %plan_name,
1174 "auto-approved plan applied"
1175 );
1176 } else {
1177 info!(name, namespace, "no changes needed");
1178 }
1179
1180 update_status(ctx, resource, |status| {
1182 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1183 status.set_condition(drifted_condition(false, "InSync", "No pending changes"));
1184 status.conditions.retain(|c| {
1185 c.condition_type != "Reconciling"
1186 && c.condition_type != "Degraded"
1187 && c.condition_type != "Conflict"
1188 && c.condition_type != "Paused"
1189 });
1190 status.observed_generation = generation;
1191 status.last_attempted_generation = generation;
1192 status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1193 status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1194 status.change_summary = Some(summary);
1195 status.last_reconcile_mode = Some(PolicyMode::Apply);
1196 status.planned_sql = None;
1197 status.planned_sql_truncated = false;
1198 status.last_error = None;
1199 status.applied_password_source_versions = applied_password_source_versions;
1200 status.transient_failure_count = 0;
1201 })
1202 .await?;
1203
1204 Ok((
1205 Action::requeue(requeue_interval),
1206 ReconcileOutcome::Reconciled,
1207 ))
1208 }
1209 crate::crd::ApprovalMode::Manual => {
1210 if let Some(current_plan) =
1214 crate::plan::get_current_actionable_plan(&ctx.kube_client, resource).await?
1215 {
1216 let approval_state = crate::plan::check_plan_approval(¤t_plan);
1217
1218 match approval_state {
1219 crate::plan::PlanApprovalState::Approved => {
1220 let fresh_sql = crate::plan::render_full_sql(&changes, &sql_ctx);
1223 let fresh_hash = crate::plan::compute_sql_hash(&fresh_sql);
1224 let stored_hash = current_plan
1225 .status
1226 .as_ref()
1227 .and_then(|s| s.sql_hash.as_deref());
1228
1229 if stored_hash != Some(&fresh_hash) {
1230 tracing::warn!(
1232 plan = %current_plan.name_any(),
1233 stored_hash = ?stored_hash,
1234 fresh_hash = %fresh_hash,
1235 "approved plan superseded: database state changed since approval"
1236 );
1237
1238 crate::plan::mark_plan_superseded(&ctx.kube_client, ¤t_plan)
1239 .await?;
1240
1241 let new_creation_result = crate::plan::create_or_update_plan(
1243 &ctx.kube_client,
1244 resource,
1245 &changes,
1246 &sql_ctx,
1247 &inspect_config,
1248 resource.spec.reconciliation_mode,
1249 identity.as_str(),
1250 &summary,
1251 )
1252 .await?;
1253 let new_plan_name = new_creation_result.plan_name().to_string();
1254
1255 if new_creation_result.is_created() {
1256 let plans_api: Api<PostgresPolicyPlan> =
1257 Api::namespaced(ctx.kube_client.clone(), namespace);
1258 let new_plan = plans_api.get(&new_plan_name).await?;
1259 emit_plan_event(
1260 ctx,
1261 resource,
1262 &new_plan,
1263 PlanEventType::Created {
1264 change_count: summary.total,
1265 },
1266 )
1267 .await;
1268 }
1269
1270 crate::plan::update_policy_plan_ref(
1271 &ctx.kube_client,
1272 resource,
1273 &new_plan_name,
1274 )
1275 .await?;
1276
1277 let msg = format!(
1278 "Plan {} superseded (DB state changed); new plan {} created with {} change(s) awaiting approval",
1279 current_plan.name_any(),
1280 new_plan_name,
1281 summary.total,
1282 );
1283 update_status(ctx, resource, |status| {
1284 status.set_condition(ready_condition(true, "Planned", &msg));
1285 status.set_condition(drifted_condition(
1286 true,
1287 "DriftDetected",
1288 &format!("{} planned change(s) pending review", summary.total),
1289 ));
1290 status.conditions.retain(|c| {
1291 c.condition_type != "Reconciling"
1292 && c.condition_type != "Degraded"
1293 && c.condition_type != "Conflict"
1294 && c.condition_type != "Paused"
1295 });
1296 status.last_attempted_generation = generation;
1297 status.change_summary = Some(summary.clone());
1298 status.last_reconcile_mode = Some(PolicyMode::Apply);
1299 status.planned_sql = planned_sql.clone();
1300 status.planned_sql_truncated = planned_sql_truncated;
1301 status.last_error = None;
1302 status.transient_failure_count = 0;
1303 status.current_plan_ref = Some(crate::crd::PlanReference {
1304 name: new_plan_name.clone(),
1305 });
1306 })
1307 .await?;
1308
1309 return Ok((
1310 Action::requeue(requeue_interval),
1311 ReconcileOutcome::Planned,
1312 ));
1313 }
1314
1315 info!(
1317 name,
1318 namespace,
1319 plan = %current_plan.name_any(),
1320 "executing manually approved plan"
1321 );
1322
1323 emit_plan_event(ctx, resource, ¤t_plan, PlanEventType::Approved)
1324 .await;
1325
1326 crate::plan::mark_plan_approved(
1327 &ctx.kube_client,
1328 ¤t_plan,
1329 "ManuallyApproved",
1330 "Plan approved via annotation",
1331 )
1332 .await?;
1333
1334 let plans_api: Api<PostgresPolicyPlan> =
1335 Api::namespaced(ctx.kube_client.clone(), namespace);
1336 let plan = plans_api.get(¤t_plan.name_any()).await?;
1337
1338 emit_plan_event(ctx, resource, &plan, PlanEventType::ApplyStarted).await;
1339
1340 match crate::plan::execute_plan(
1341 &ctx.kube_client,
1342 &plan,
1343 pool,
1344 &sql_ctx,
1345 &changes,
1346 )
1347 .await
1348 {
1349 Ok(()) => {
1350 emit_plan_event(
1351 ctx,
1352 resource,
1353 &plan,
1354 PlanEventType::ApplySucceeded,
1355 )
1356 .await;
1357 }
1358 Err(err) => {
1359 emit_plan_event(
1360 ctx,
1361 resource,
1362 &plan,
1363 PlanEventType::ApplyFailed {
1364 error: err.to_string(),
1365 },
1366 )
1367 .await;
1368 return Err(err);
1369 }
1370 }
1371
1372 ctx.observability.record_apply_result("success");
1373
1374 update_status(ctx, resource, |status| {
1376 status.set_condition(ready_condition(
1377 true,
1378 "Reconciled",
1379 "Approved plan applied",
1380 ));
1381 status.set_condition(drifted_condition(
1382 false,
1383 "InSync",
1384 "No pending changes",
1385 ));
1386 status.conditions.retain(|c| {
1387 c.condition_type != "Reconciling"
1388 && c.condition_type != "Degraded"
1389 && c.condition_type != "Conflict"
1390 && c.condition_type != "Paused"
1391 });
1392 status.observed_generation = generation;
1393 status.last_attempted_generation = generation;
1394 status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1395 status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1396 status.change_summary = Some(summary);
1397 status.last_reconcile_mode = Some(PolicyMode::Apply);
1398 status.planned_sql = None;
1399 status.planned_sql_truncated = false;
1400 status.last_error = None;
1401 status.applied_password_source_versions =
1402 applied_password_source_versions;
1403 status.transient_failure_count = 0;
1404 })
1405 .await?;
1406
1407 return Ok((
1408 Action::requeue(requeue_interval),
1409 ReconcileOutcome::Reconciled,
1410 ));
1411 }
1412 crate::plan::PlanApprovalState::Rejected => {
1413 crate::plan::mark_plan_rejected(&ctx.kube_client, ¤t_plan).await?;
1414 emit_plan_event(ctx, resource, ¤t_plan, PlanEventType::Rejected)
1415 .await;
1416 info!(
1417 name,
1418 namespace,
1419 plan = %current_plan.name_any(),
1420 "plan rejected via annotation"
1421 );
1422
1423 update_status(ctx, resource, |status| {
1426 status.set_condition(ready_condition(
1427 true,
1428 "Planned",
1429 &format!(
1430 "Plan {} rejected; new plan will be created on next reconcile",
1431 current_plan.name_any()
1432 ),
1433 ));
1434 status.last_attempted_generation = generation;
1435 status.last_error = None;
1436 status.transient_failure_count = 0;
1437 status.current_plan_ref = None;
1438 })
1439 .await?;
1440
1441 return Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned));
1442 }
1443 crate::plan::PlanApprovalState::Pending => {
1444 info!(
1446 name,
1447 namespace,
1448 plan = %current_plan.name_any(),
1449 "plan awaiting manual approval"
1450 );
1451
1452 update_status(ctx, resource, |status| {
1453 let msg = format!(
1454 "Plan {} awaiting approval; {} change(s) pending",
1455 current_plan.name_any(),
1456 summary.total,
1457 );
1458 status.set_condition(ready_condition(true, "Planned", &msg));
1459 status.set_condition(drifted_condition(
1460 !changes.is_empty(),
1461 if changes.is_empty() {
1462 "InSync"
1463 } else {
1464 "DriftDetected"
1465 },
1466 &msg,
1467 ));
1468 status.conditions.retain(|c| {
1469 c.condition_type != "Reconciling"
1470 && c.condition_type != "Degraded"
1471 && c.condition_type != "Conflict"
1472 && c.condition_type != "Paused"
1473 });
1474 status.last_attempted_generation = generation;
1475 status.change_summary = Some(summary.clone());
1476 status.planned_sql = planned_sql.clone();
1477 status.planned_sql_truncated = planned_sql_truncated;
1478 status.last_error = None;
1479 status.transient_failure_count = 0;
1480 })
1481 .await?;
1482
1483 return Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned));
1484 }
1485 }
1486 }
1487
1488 if changes.is_empty() {
1490 info!(name, namespace, "no changes needed (manual approval mode)");
1491
1492 update_status(ctx, resource, |status| {
1493 status.set_condition(ready_condition(true, "Reconciled", "No changes needed"));
1494 status.set_condition(drifted_condition(false, "InSync", "No pending changes"));
1495 status.conditions.retain(|c| {
1496 c.condition_type != "Reconciling"
1497 && c.condition_type != "Degraded"
1498 && c.condition_type != "Conflict"
1499 && c.condition_type != "Paused"
1500 });
1501 status.observed_generation = generation;
1502 status.last_attempted_generation = generation;
1503 status.last_successful_reconcile_time = Some(crate::crd::now_rfc3339());
1504 status.last_reconcile_time = Some(crate::crd::now_rfc3339());
1505 status.change_summary = Some(summary);
1506 status.last_reconcile_mode = Some(PolicyMode::Apply);
1507 status.planned_sql = None;
1508 status.planned_sql_truncated = false;
1509 status.last_error = None;
1510 status.applied_password_source_versions = applied_password_source_versions;
1511 status.transient_failure_count = 0;
1512 })
1513 .await?;
1514
1515 return Ok((
1516 Action::requeue(requeue_interval),
1517 ReconcileOutcome::Reconciled,
1518 ));
1519 }
1520
1521 let creation_result = crate::plan::create_or_update_plan(
1523 &ctx.kube_client,
1524 resource,
1525 &changes,
1526 &sql_ctx,
1527 &inspect_config,
1528 resource.spec.reconciliation_mode,
1529 identity.as_str(),
1530 &summary,
1531 )
1532 .await?;
1533 let plan_name = creation_result.plan_name().to_string();
1534
1535 if creation_result.is_created() {
1537 let plans_api: Api<PostgresPolicyPlan> =
1538 Api::namespaced(ctx.kube_client.clone(), namespace);
1539 let created_plan = plans_api.get(&plan_name).await?;
1540 emit_plan_event(
1541 ctx,
1542 resource,
1543 &created_plan,
1544 PlanEventType::Created {
1545 change_count: summary.total,
1546 },
1547 )
1548 .await;
1549 }
1550
1551 crate::plan::update_policy_plan_ref(&ctx.kube_client, resource, &plan_name).await?;
1552
1553 let msg = format!(
1554 "Plan {plan_name} created; {} change(s) awaiting approval",
1555 summary.total,
1556 );
1557 update_status(ctx, resource, |status| {
1558 status.set_condition(ready_condition(true, "Planned", &msg));
1559 status.set_condition(drifted_condition(
1560 true,
1561 "DriftDetected",
1562 &format!("{} planned change(s) pending review", summary.total),
1563 ));
1564 status.conditions.retain(|c| {
1565 c.condition_type != "Reconciling"
1566 && c.condition_type != "Degraded"
1567 && c.condition_type != "Conflict"
1568 && c.condition_type != "Paused"
1569 });
1570 status.last_attempted_generation = generation;
1571 status.change_summary = Some(summary.clone());
1572 status.last_reconcile_mode = Some(PolicyMode::Apply);
1573 status.planned_sql = planned_sql.clone();
1574 status.planned_sql_truncated = planned_sql_truncated;
1575 status.last_error = None;
1576 status.transient_failure_count = 0;
1577 status.current_plan_ref = Some(crate::crd::PlanReference {
1578 name: plan_name.clone(),
1579 });
1580 })
1581 .await?;
1582
1583 info!(
1584 name,
1585 namespace,
1586 total = summary.total,
1587 plan = %plan_name,
1588 "plan created, awaiting manual approval"
1589 );
1590
1591 Ok((Action::requeue(requeue_interval), ReconcileOutcome::Planned))
1592 }
1593 }
1594}
1595
1596async fn resolve_passwords_from_secrets(
1608 ctx: &OperatorContext,
1609 resource: &PostgresPolicy,
1610 namespace: &str,
1611) -> Result<std::collections::BTreeMap<String, ResolvedPassword>, ReconcileError> {
1612 use k8s_openapi::api::core::v1::Secret;
1613
1614 let mut resolved = std::collections::BTreeMap::new();
1615
1616 let mut secret_cache: std::collections::BTreeMap<String, Secret> =
1619 std::collections::BTreeMap::new();
1620
1621 let secrets_api: kube::Api<Secret> = kube::Api::namespaced(ctx.kube_client.clone(), namespace);
1622
1623 for role_spec in &resource.spec.roles {
1625 if role_spec.external {
1626 continue;
1627 }
1628 if let Some(pw) = &role_spec.password
1629 && let Some(secret_ref) = &pw.secret_ref
1630 {
1631 let secret_name = &secret_ref.name;
1632 if !secret_cache.contains_key(secret_name.as_str()) {
1633 let fetched = secrets_api.get(secret_name).await.map_err(|err| {
1634 Box::new(crate::context::ContextError::SecretFetch {
1635 name: secret_name.clone(),
1636 namespace: namespace.to_string(),
1637 source: err,
1638 })
1639 })?;
1640 secret_cache.insert(secret_name.clone(), fetched);
1641 }
1642 }
1643 }
1644
1645 for role_spec in &resource.spec.roles {
1647 if role_spec.external {
1648 continue;
1649 }
1650 if let Some(pw) = &role_spec.password {
1651 if let Some(gen_spec) = &pw.generate {
1652 let password = if resource.spec.mode == PolicyMode::Plan {
1653 match crate::password::get_generated_secret(
1654 ctx.kube_client.clone(),
1655 namespace,
1656 &resource.name_any(),
1657 &role_spec.name,
1658 gen_spec,
1659 )
1660 .await
1661 .map_err(Box::new)?
1662 {
1663 Some(existing) => existing,
1664 None => {
1665 let secret_name = crate::password::generated_secret_name(
1666 &resource.name_any(),
1667 &role_spec.name,
1668 gen_spec,
1669 );
1670 let secret_key = crate::password::generated_secret_key(gen_spec);
1671 let cleartext = crate::password::generate_password(
1672 gen_spec
1673 .length
1674 .unwrap_or(crate::password::DEFAULT_PASSWORD_LENGTH),
1675 );
1676
1677 crate::password::GeneratedPasswordSecret {
1678 password: cleartext,
1679 source_version:
1680 crate::password::missing_generated_secret_source_version(
1681 &secret_name,
1682 &secret_key,
1683 ),
1684 }
1685 }
1686 }
1687 } else {
1688 crate::password::ensure_generated_secret(
1690 ctx.kube_client.clone(),
1691 namespace,
1692 resource,
1693 &role_spec.name,
1694 gen_spec,
1695 )
1696 .await
1697 .map_err(Box::new)?
1698 };
1699 resolved.insert(
1700 role_spec.name.clone(),
1701 ResolvedPassword {
1702 cleartext: password.password,
1703 source_version: password.source_version,
1704 },
1705 );
1706 } else if pw.secret_ref.is_some() {
1707 let password = resolve_password_from_cache(&role_spec.name, pw, &secret_cache)?;
1709 resolved.insert(role_spec.name.clone(), password);
1710 }
1711 }
1712 }
1713
1714 Ok(resolved)
1715}
1716
1717fn resolve_password_from_cache(
1719 role_name: &str,
1720 password_spec: &crate::crd::PasswordSpec,
1721 secret_cache: &std::collections::BTreeMap<String, k8s_openapi::api::core::v1::Secret>,
1722) -> Result<ResolvedPassword, ReconcileError> {
1723 let secret_ref = password_spec.secret_ref.as_ref().ok_or_else(|| {
1724 Box::new(crate::context::ContextError::SecretMissing {
1725 name: "(no secretRef)".to_string(),
1726 key: role_name.to_string(),
1727 })
1728 })?;
1729 let secret_name = &secret_ref.name;
1730 let secret_key = password_spec.secret_key.as_deref().unwrap_or(role_name);
1731
1732 let secret = secret_cache.get(secret_name.as_str()).ok_or_else(|| {
1733 Box::new(crate::context::ContextError::SecretMissing {
1734 name: secret_name.clone(),
1735 key: secret_key.to_string(),
1736 })
1737 })?;
1738
1739 let data = secret.data.as_ref().ok_or_else(|| {
1740 Box::new(crate::context::ContextError::SecretMissing {
1741 name: secret_name.clone(),
1742 key: secret_key.to_string(),
1743 })
1744 })?;
1745
1746 let value_bytes = data.get(secret_key).ok_or_else(|| {
1747 Box::new(crate::context::ContextError::SecretMissing {
1748 name: secret_name.clone(),
1749 key: secret_key.to_string(),
1750 })
1751 })?;
1752
1753 let password = String::from_utf8(value_bytes.0.clone()).map_err(|_| {
1754 Box::new(crate::context::ContextError::SecretMissing {
1755 name: secret_name.clone(),
1756 key: secret_key.to_string(),
1757 })
1758 })?;
1759
1760 if password.is_empty() {
1761 return Err(ReconcileError::EmptyPasswordSecret {
1762 role: role_name.to_string(),
1763 secret: secret_name.clone(),
1764 key: secret_key.to_string(),
1765 });
1766 }
1767
1768 let resource_version = secret
1769 .metadata
1770 .resource_version
1771 .as_deref()
1772 .unwrap_or("unknown");
1773 Ok(ResolvedPassword {
1774 cleartext: password,
1775 source_version: format!("{secret_name}:{secret_key}:{resource_version}"),
1776 })
1777}
1778
1779#[cfg(test)]
1781fn resolve_passwords_from_cached_secrets(
1782 resource: &PostgresPolicy,
1783 secret_cache: &std::collections::BTreeMap<String, k8s_openapi::api::core::v1::Secret>,
1784) -> Result<std::collections::BTreeMap<String, ResolvedPassword>, ReconcileError> {
1785 let mut resolved = std::collections::BTreeMap::new();
1786 for role_spec in &resource.spec.roles {
1787 if role_spec.external {
1788 continue;
1789 }
1790 if let Some(pw) = &role_spec.password
1791 && pw.secret_ref.is_some()
1792 {
1793 let password = resolve_password_from_cache(&role_spec.name, pw, secret_cache)?;
1794 resolved.insert(role_spec.name.clone(), password);
1795 }
1796 }
1797 Ok(resolved)
1798}
1799
1800fn select_password_changes(
1801 changes: &[pgroles_core::diff::Change],
1802 resolved_passwords: &std::collections::BTreeMap<String, ResolvedPassword>,
1803 status: Option<&PostgresPolicyStatus>,
1804) -> (
1805 std::collections::BTreeMap<String, String>,
1806 std::collections::BTreeMap<String, String>,
1807) {
1808 let created_roles: std::collections::BTreeSet<&str> = changes
1809 .iter()
1810 .filter_map(|change| match change {
1811 pgroles_core::diff::Change::CreateRole { name, .. } => Some(name.as_str()),
1812 _ => None,
1813 })
1814 .collect();
1815 let previous_versions = status
1816 .map(|status| &status.applied_password_source_versions)
1817 .cloned()
1818 .unwrap_or_default();
1819
1820 let mut password_changes = std::collections::BTreeMap::new();
1821 let mut current_versions = std::collections::BTreeMap::new();
1822
1823 for (role, resolved) in resolved_passwords {
1824 current_versions.insert(role.clone(), resolved.source_version.clone());
1825 if created_roles.contains(role.as_str())
1826 || previous_versions.get(role) != Some(&resolved.source_version)
1827 {
1828 password_changes.insert(role.clone(), resolved.cleartext.clone());
1829 }
1830 }
1831
1832 (password_changes, current_versions)
1833}
1834
1835async fn reconcile_cleanup(
1837 resource: &PostgresPolicy,
1838 ctx: &OperatorContext,
1839) -> Result<Action, ReconcileError> {
1840 let name = resource.name_any();
1841 let namespace = resource.namespace().ok_or(ReconcileError::NoNamespace)?;
1842
1843 info!(name, namespace, "cleaning up (resource deleted)");
1844
1845 ctx.evict_pool(&namespace, &resource.spec.connection).await;
1847
1848 Ok(Action::await_change())
1853}
1854
1855fn accumulate_summary(summary: &mut ChangeSummary, change: &pgroles_core::diff::Change) {
1857 use pgroles_core::diff::Change;
1858 match change {
1859 Change::CreateRole { .. } => summary.roles_created += 1,
1860 Change::CreateSchema { .. } => summary.schemas_created += 1,
1861 Change::AlterSchemaOwner { .. } => summary.schema_owners_altered += 1,
1862 Change::AlterRole { .. } => summary.roles_altered += 1,
1863 Change::SetComment { .. } => summary.roles_altered += 1,
1864 Change::DropRole { .. } => summary.roles_dropped += 1,
1865 Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
1866 Change::ReassignOwned { .. } => {}
1867 Change::DropOwned { .. } => {}
1868 Change::Grant { .. } | Change::EnsureSchemaOwnerPrivileges { .. } => {
1869 summary.grants_added += 1
1870 }
1871 Change::Revoke { .. } => summary.grants_revoked += 1,
1872 Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
1873 Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
1874 Change::AddMember { .. } => summary.members_added += 1,
1875 Change::RemoveMember { .. } => summary.members_removed += 1,
1876 Change::SetPassword { .. } => summary.passwords_set += 1,
1877 }
1878}
1879
1880fn summarize_changes(changes: &[pgroles_core::diff::Change]) -> ChangeSummary {
1881 let mut summary = ChangeSummary::default();
1882 for change in changes {
1883 accumulate_summary(&mut summary, change);
1884 }
1885 summary.total = summary.roles_created
1886 + summary.roles_altered
1887 + summary.schemas_created
1888 + summary.schema_owners_altered
1889 + summary.roles_dropped
1890 + summary.sessions_terminated
1891 + summary.grants_added
1892 + summary.grants_revoked
1893 + summary.default_privileges_set
1894 + summary.default_privileges_revoked
1895 + summary.members_added
1896 + summary.members_removed
1897 + summary.passwords_set;
1898 summary
1899}
1900
1901fn parse_rfc3339_to_epoch_secs(timestamp: &str) -> Option<u64> {
1906 if timestamp.len() < 20 || !timestamp.ends_with('Z') {
1908 return None;
1909 }
1910 let year: u64 = timestamp.get(0..4)?.parse().ok()?;
1911 let month: u64 = timestamp.get(5..7)?.parse().ok()?;
1912 let day: u64 = timestamp.get(8..10)?.parse().ok()?;
1913 let hours: u64 = timestamp.get(11..13)?.parse().ok()?;
1914 let minutes: u64 = timestamp.get(14..16)?.parse().ok()?;
1915 let seconds: u64 = timestamp.get(17..19)?.parse().ok()?;
1916
1917 let (y, m) = if month <= 2 {
1919 (year - 1, month + 9)
1920 } else {
1921 (year, month - 3)
1922 };
1923 let era = y / 400;
1924 let yoe = y - era * 400;
1925 let doy = (153 * m + 2) / 5 + day - 1;
1926 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1927 let days_since_epoch = era * 146097 + doe - 719468;
1928
1929 Some(days_since_epoch * 86400 + hours * 3600 + minutes * 60 + seconds)
1930}
1931
1932async fn detect_sql_context(
1933 pool: &sqlx::PgPool,
1934 inspect_config: &pgroles_inspect::InspectConfig,
1935) -> Result<pgroles_core::sql::SqlContext, ReconcileError> {
1936 let pg_version = pgroles_inspect::detect_pg_version(pool).await?;
1937 let privilege_schemas: Vec<&str> = inspect_config
1938 .privilege_schemas
1939 .iter()
1940 .map(|schema| schema.as_str())
1941 .collect();
1942 let relation_inventory =
1943 pgroles_inspect::fetch_relation_inventory(pool, &privilege_schemas).await?;
1944 Ok(
1945 pgroles_core::sql::SqlContext::from_version_num(pg_version.version_num)
1946 .with_relation_inventory(relation_inventory),
1947 )
1948}
1949
1950fn render_plan_sql_for_status(
1951 changes: &[pgroles_core::diff::Change],
1952 sql_ctx: &pgroles_core::sql::SqlContext,
1953) -> (Option<String>, bool) {
1954 if changes.is_empty() {
1955 return (None, false);
1956 }
1957
1958 let rendered: String = changes
1960 .iter()
1961 .flat_map(|change| {
1962 if let pgroles_core::diff::Change::SetPassword { name, .. } = change {
1963 vec![format!(
1964 "ALTER ROLE {} PASSWORD '[REDACTED]';",
1965 pgroles_core::sql::quote_ident(name)
1966 )]
1967 } else {
1968 pgroles_core::sql::render_statements_with_context(change, sql_ctx)
1969 }
1970 })
1971 .collect::<Vec<_>>()
1972 .join("\n");
1973
1974 let (truncated, did_truncate) = truncate_status_text(&rendered, MAX_PLANNED_SQL_STATUS_BYTES);
1975 (Some(truncated), did_truncate)
1976}
1977
1978fn truncate_status_text(text: &str, max_bytes: usize) -> (String, bool) {
1979 if text.len() <= max_bytes {
1980 return (text.to_string(), false);
1981 }
1982
1983 let marker = "\n-- truncated for status --";
1984 let target_len = max_bytes.saturating_sub(marker.len());
1985 let mut end = target_len.min(text.len());
1986 while end > 0 && !text.is_char_boundary(end) {
1987 end -= 1;
1988 }
1989
1990 let mut truncated = text[..end].to_string();
1991 truncated.push_str(marker);
1992 (truncated, true)
1993}
1994
1995async fn emit_plan_event(
1997 ctx: &OperatorContext,
1998 policy: &PostgresPolicy,
1999 plan: &PostgresPolicyPlan,
2000 event_type: PlanEventType,
2001) {
2002 if let Err(error) = publish_plan_event(&ctx.event_recorder, policy, plan, event_type).await {
2003 let namespace = policy.namespace().unwrap_or_default();
2004 let name = policy.name_any();
2005 tracing::warn!(
2006 policy = %format!("{namespace}/{name}"),
2007 %error,
2008 "failed to publish plan lifecycle event"
2009 );
2010 }
2011}
2012
2013async fn update_status<F>(
2015 ctx: &OperatorContext,
2016 resource: &PostgresPolicy,
2017 mutate: F,
2018) -> Result<(), ReconcileError>
2019where
2020 F: FnOnce(&mut PostgresPolicyStatus),
2021{
2022 let namespace = resource.namespace().ok_or(ReconcileError::NoNamespace)?;
2023 let name = resource.name_any();
2024
2025 let api: Api<PostgresPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
2026 let latest = api.get(&name).await?;
2027 let old_status = latest.status.clone();
2028 let mut status = old_status.clone().unwrap_or_default();
2029
2030 mutate(&mut status);
2031
2032 let patch = serde_json::json!({
2033 "status": status
2034 });
2035
2036 api.patch_status(
2037 &name,
2038 &PatchParams::apply("pgroles-operator"),
2039 &Patch::Merge(&patch),
2040 )
2041 .await?;
2042
2043 if let Err(error) =
2044 publish_status_events(&ctx.event_recorder, &latest, old_status.as_ref(), &status).await
2045 {
2046 tracing::warn!(policy = %format!("{namespace}/{name}"), %error, "failed to publish Kubernetes Events");
2047 }
2048
2049 Ok(())
2050}
2051
2052async fn detect_policy_conflict(
2053 ctx: &OperatorContext,
2054 resource: &PostgresPolicy,
2055 identity: &DatabaseIdentity,
2056 ownership: &crate::crd::OwnershipClaims,
2057) -> Result<Option<String>, ReconcileError> {
2058 let api: Api<PostgresPolicy> = Api::all(ctx.kube_client.clone());
2059 let policies = api.list(&Default::default()).await?;
2060
2061 Ok(detect_policy_conflict_in_list(
2062 resource,
2063 identity,
2064 ownership,
2065 policies.into_iter(),
2066 ))
2067}
2068
2069fn detect_policy_conflict_in_list(
2070 resource: &PostgresPolicy,
2071 identity: &DatabaseIdentity,
2072 ownership: &crate::crd::OwnershipClaims,
2073 policies: impl IntoIterator<Item = PostgresPolicy>,
2074) -> Option<String> {
2075 let this_ns = resource.namespace()?;
2076 let this_name = resource.name_any();
2077
2078 let mut conflicts = Vec::new();
2079 for other in policies {
2080 let other_ns = match other.namespace() {
2081 Some(ns) => ns,
2082 None => continue,
2083 };
2084 let other_name = other.name_any();
2085 if other_ns == this_ns && other_name == this_name {
2086 continue;
2087 }
2088
2089 let other_identity = DatabaseIdentity::from_connection(&other_ns, &other.spec.connection);
2090 if &other_identity != identity {
2091 continue;
2092 }
2093
2094 if let Err(error) = other.spec.validate_password_specs(&other_name) {
2095 tracing::warn!(
2096 policy = %format!("{other_ns}/{other_name}"),
2097 database = %identity.as_str(),
2098 %error,
2099 "skipping conflict detection for invalid peer policy"
2100 );
2101 continue;
2102 }
2103
2104 let other_ownership = match other.spec.ownership_claims() {
2105 Ok(claims) => claims,
2106 Err(error) => {
2107 tracing::warn!(
2108 policy = %format!("{other_ns}/{other_name}"),
2109 database = %identity.as_str(),
2110 %error,
2111 "skipping conflict detection for invalid peer policy"
2112 );
2113 continue;
2114 }
2115 };
2116 if ownership.overlaps(&other_ownership) {
2117 let overlap = ownership.overlap_summary(&other_ownership);
2118 conflicts.push(format!("{other_ns}/{other_name} ({overlap})"));
2119 }
2120 }
2121
2122 if conflicts.is_empty() {
2123 None
2124 } else {
2125 Some(format!(
2126 "policy ownership overlaps with {} on database target {}",
2127 conflicts.join(", "),
2128 identity.as_str()
2129 ))
2130 }
2131}
2132
2133impl ReconcileError {
2134 fn reason(&self) -> &'static str {
2135 match self {
2136 ReconcileError::ManifestExpansion(_)
2137 | ReconcileError::InvalidInterval(_, _)
2138 | ReconcileError::InvalidSpec(_) => "InvalidSpec",
2139 ReconcileError::ConflictingPolicy(_) => "ConflictingPolicy",
2140 ReconcileError::UnsatisfiableWildcardGrant(_) => "UnsatisfiableWildcardGrant",
2141 ReconcileError::LockContention(_, _) => "LockContention",
2142 ReconcileError::Context(context) => match context.as_ref() {
2143 ContextError::SecretFetch { .. } => "SecretFetchFailed",
2144 ContextError::SecretMissing { .. } => "SecretMissing",
2145 ContextError::GcpAuthHttp { .. }
2146 | ContextError::GcpAuthRejected { .. }
2147 | ContextError::GcpAuthInvalidResponse { .. } => "GcpAuthFailed",
2148 ContextError::DatabaseConnect { .. } => "DatabaseConnectionFailed",
2149 ContextError::SetRoleFailed { .. } => "SetRoleFailed",
2150 ContextError::EmptyResolvedValue { .. } => "InvalidConnectionParams",
2151 ContextError::InvalidResolvedSslMode { .. } => "InvalidConnectionParams",
2152 },
2153 ReconcileError::Inspect(error) => match error {
2154 pgroles_inspect::InspectError::Database(sql_err) => {
2155 match classify_sqlx_error(sql_err) {
2156 SqlErrorKind::InsufficientPrivileges => "InsufficientPrivileges",
2157 SqlErrorKind::MissingDatabaseObject => "MissingDatabaseObject",
2158 SqlErrorKind::Transient => "DatabaseInspectionFailed",
2159 }
2160 }
2161 },
2162 ReconcileError::SqlExec(error) => match classify_sqlx_error(error) {
2163 SqlErrorKind::InsufficientPrivileges => "InsufficientPrivileges",
2164 SqlErrorKind::MissingDatabaseObject => "MissingDatabaseObject",
2165 SqlErrorKind::Transient => "ApplyFailed",
2166 },
2167 ReconcileError::UnsafeRoleDrops(_) => "UnsafeRoleDrops",
2168 ReconcileError::EmptyPasswordSecret { .. } => "InvalidSpec",
2169 ReconcileError::MissingDatabaseObjects(_) => "MissingDatabaseObject",
2170 ReconcileError::PasswordGeneration(_) => "SecretFetchFailed",
2171 ReconcileError::PlanSqlStorage(_) => "PlanSqlStorageFailed",
2172 ReconcileError::Kube(_) => "KubernetesApiError",
2173 ReconcileError::NoNamespace => "InvalidResource",
2174 }
2175 }
2176}
2177
2178#[cfg(test)]
2183mod tests {
2184 use super::*;
2185 use crate::crd::{
2186 ConnectionSpec, CrdReconciliationMode, PasswordSpec, PolicyMode, PostgresPolicySpec,
2187 RoleSpec, SecretReference,
2188 };
2189 use k8s_openapi::{
2190 ByteString, api::core::v1::Secret, apimachinery::pkg::apis::meta::v1::ObjectMeta,
2191 };
2192 use sqlx::error::{DatabaseError, ErrorKind};
2193 use std::borrow::Cow;
2194 use std::collections::BTreeMap;
2195 use std::error::Error as StdError;
2196 use std::fmt;
2197
2198 #[derive(Debug)]
2199 struct TestDatabaseError {
2200 message: String,
2201 code: Option<&'static str>,
2202 }
2203
2204 impl fmt::Display for TestDatabaseError {
2205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2206 f.write_str(&self.message)
2207 }
2208 }
2209
2210 impl StdError for TestDatabaseError {}
2211
2212 impl DatabaseError for TestDatabaseError {
2213 fn message(&self) -> &str {
2214 &self.message
2215 }
2216
2217 fn code(&self) -> Option<Cow<'_, str>> {
2218 self.code.map(Cow::Borrowed)
2219 }
2220
2221 fn as_error(&self) -> &(dyn StdError + Send + Sync + 'static) {
2222 self
2223 }
2224
2225 fn as_error_mut(&mut self) -> &mut (dyn StdError + Send + Sync + 'static) {
2226 self
2227 }
2228
2229 fn into_error(self: Box<Self>) -> Box<dyn StdError + Send + Sync + 'static> {
2230 self
2231 }
2232
2233 fn kind(&self) -> ErrorKind {
2234 ErrorKind::Other
2235 }
2236 }
2237
2238 fn insufficient_privilege_sqlx_error() -> sqlx::Error {
2239 sqlx::Error::Database(Box::new(TestDatabaseError {
2240 message: "permission denied to create role".to_string(),
2241 code: Some(SQLSTATE_INSUFFICIENT_PRIVILEGE),
2242 }))
2243 }
2244
2245 fn missing_schema_sqlx_error() -> sqlx::Error {
2246 sqlx::Error::Database(Box::new(TestDatabaseError {
2247 message: "schema \"etl\" does not exist".to_string(),
2248 code: Some(SQLSTATE_INVALID_SCHEMA_NAME),
2249 }))
2250 }
2251
2252 fn missing_table_sqlx_error() -> sqlx::Error {
2253 sqlx::Error::Database(Box::new(TestDatabaseError {
2254 message: "relation \"foo\" does not exist".to_string(),
2255 code: Some(SQLSTATE_UNDEFINED_TABLE),
2256 }))
2257 }
2258
2259 fn missing_function_sqlx_error() -> sqlx::Error {
2260 sqlx::Error::Database(Box::new(TestDatabaseError {
2261 message: "function foo() does not exist".to_string(),
2262 code: Some(SQLSTATE_UNDEFINED_FUNCTION),
2263 }))
2264 }
2265
2266 fn missing_object_sqlx_error() -> sqlx::Error {
2267 sqlx::Error::Database(Box::new(TestDatabaseError {
2268 message: "role \"nope\" does not exist".to_string(),
2269 code: Some(SQLSTATE_UNDEFINED_OBJECT),
2270 }))
2271 }
2272
2273 fn transient_sqlx_error() -> sqlx::Error {
2274 sqlx::Error::Database(Box::new(TestDatabaseError {
2275 message: "connection timed out".to_string(),
2276 code: Some("08006"),
2277 }))
2278 }
2279
2280 fn test_policy(interval: &str, transient_failure_count: i32) -> Arc<PostgresPolicy> {
2281 let spec = PostgresPolicySpec {
2282 connection: ConnectionSpec {
2283 secret_ref: Some(SecretReference {
2284 name: "db-credentials".to_string(),
2285 }),
2286 secret_key: Some("DATABASE_URL".to_string()),
2287 params: None,
2288 },
2289 interval: interval.to_string(),
2290 suspend: false,
2291 mode: PolicyMode::Apply,
2292 reconciliation_mode: CrdReconciliationMode::default(),
2293 default_owner: None,
2294 profiles: Default::default(),
2295 schemas: Vec::new(),
2296 roles: Vec::new(),
2297 grants: Vec::new(),
2298 default_privileges: Vec::new(),
2299 memberships: Vec::new(),
2300 retirements: Vec::new(),
2301 approval: None,
2302 };
2303 let mut resource = PostgresPolicy::new("example", spec);
2304 resource.metadata.namespace = Some("default".to_string());
2305 resource.status = Some(PostgresPolicyStatus {
2306 transient_failure_count,
2307 ..Default::default()
2308 });
2309 Arc::new(resource)
2310 }
2311
2312 fn test_policy_with_spec(name: &str, spec: PostgresPolicySpec) -> PostgresPolicy {
2313 let mut resource = PostgresPolicy::new(name, spec);
2314 resource.metadata.namespace = Some("default".to_string());
2315 resource
2316 }
2317
2318 fn valid_role_policy(name: &str, role_name: &str, secret_name: &str) -> PostgresPolicy {
2319 test_policy_with_spec(
2320 name,
2321 PostgresPolicySpec {
2322 connection: ConnectionSpec {
2323 secret_ref: Some(SecretReference {
2324 name: secret_name.to_string(),
2325 }),
2326 secret_key: Some("DATABASE_URL".to_string()),
2327 params: None,
2328 },
2329 interval: "5m".to_string(),
2330 suspend: false,
2331 mode: PolicyMode::Apply,
2332 reconciliation_mode: CrdReconciliationMode::default(),
2333 default_owner: None,
2334 profiles: Default::default(),
2335 schemas: Vec::new(),
2336 roles: vec![RoleSpec {
2337 name: role_name.to_string(),
2338 external: false,
2339 login: Some(true),
2340 superuser: None,
2341 createdb: None,
2342 createrole: None,
2343 inherit: None,
2344 replication: None,
2345 bypassrls: None,
2346 connection_limit: None,
2347 comment: None,
2348 password: None,
2349 password_valid_until: None,
2350 }],
2351 grants: Vec::new(),
2352 default_privileges: Vec::new(),
2353 memberships: Vec::new(),
2354 retirements: Vec::new(),
2355 approval: None,
2356 },
2357 )
2358 }
2359
2360 fn invalid_profile_policy(name: &str, secret_name: &str) -> PostgresPolicy {
2361 test_policy_with_spec(
2362 name,
2363 PostgresPolicySpec {
2364 connection: ConnectionSpec {
2365 secret_ref: Some(SecretReference {
2366 name: secret_name.to_string(),
2367 }),
2368 secret_key: Some("DATABASE_URL".to_string()),
2369 params: None,
2370 },
2371 interval: "5m".to_string(),
2372 suspend: false,
2373 mode: PolicyMode::Apply,
2374 reconciliation_mode: CrdReconciliationMode::default(),
2375 default_owner: None,
2376 profiles: Default::default(),
2377 schemas: vec![pgroles_core::manifest::SchemaBinding {
2378 name: "reporting".to_string(),
2379 profiles: vec!["missing-profile".to_string()],
2380 role_pattern: "{schema}-{profile}".to_string(),
2381 owner: None,
2382 }],
2383 roles: Vec::new(),
2384 grants: Vec::new(),
2385 default_privileges: Vec::new(),
2386 memberships: Vec::new(),
2387 retirements: Vec::new(),
2388 approval: None,
2389 },
2390 )
2391 }
2392
2393 fn password_role_policy() -> PostgresPolicy {
2394 test_policy_with_spec(
2395 "password-policy",
2396 PostgresPolicySpec {
2397 connection: ConnectionSpec {
2398 secret_ref: Some(SecretReference {
2399 name: "db-credentials".to_string(),
2400 }),
2401 secret_key: Some("DATABASE_URL".to_string()),
2402 params: None,
2403 },
2404 interval: "5m".to_string(),
2405 suspend: false,
2406 mode: PolicyMode::Apply,
2407 reconciliation_mode: CrdReconciliationMode::default(),
2408 default_owner: None,
2409 profiles: Default::default(),
2410 schemas: Vec::new(),
2411 roles: vec![
2412 RoleSpec {
2413 name: "app".to_string(),
2414 external: false,
2415 login: Some(true),
2416 superuser: None,
2417 createdb: None,
2418 createrole: None,
2419 inherit: None,
2420 replication: None,
2421 bypassrls: None,
2422 connection_limit: None,
2423 comment: None,
2424 password: Some(PasswordSpec {
2425 secret_ref: Some(SecretReference {
2426 name: "role-passwords".to_string(),
2427 }),
2428 secret_key: None,
2429 generate: None,
2430 }),
2431 password_valid_until: None,
2432 },
2433 RoleSpec {
2434 name: "reporter".to_string(),
2435 external: false,
2436 login: Some(true),
2437 superuser: None,
2438 createdb: None,
2439 createrole: None,
2440 inherit: None,
2441 replication: None,
2442 bypassrls: None,
2443 connection_limit: None,
2444 comment: None,
2445 password: Some(PasswordSpec {
2446 secret_ref: Some(SecretReference {
2447 name: "role-passwords".to_string(),
2448 }),
2449 secret_key: Some("reporter-password".to_string()),
2450 generate: None,
2451 }),
2452 password_valid_until: None,
2453 },
2454 ],
2455 grants: Vec::new(),
2456 default_privileges: Vec::new(),
2457 memberships: Vec::new(),
2458 retirements: Vec::new(),
2459 approval: None,
2460 },
2461 )
2462 }
2463
2464 fn secret_with_keys(name: &str, entries: &[(&str, &str)]) -> Secret {
2465 secret_with_keys_and_version(name, "1", entries)
2466 }
2467
2468 fn secret_with_keys_and_version(
2469 name: &str,
2470 resource_version: &str,
2471 entries: &[(&str, &str)],
2472 ) -> Secret {
2473 Secret {
2474 metadata: ObjectMeta {
2475 name: Some(name.to_string()),
2476 resource_version: Some(resource_version.to_string()),
2477 ..Default::default()
2478 },
2479 data: Some(
2480 entries
2481 .iter()
2482 .map(|(key, value)| ((*key).to_string(), ByteString(value.as_bytes().to_vec())))
2483 .collect(),
2484 ),
2485 ..Default::default()
2486 }
2487 }
2488
2489 #[test]
2490 fn parse_interval_minutes() {
2491 let d = parse_interval("5m").unwrap();
2492 assert_eq!(d, Duration::from_secs(300));
2493 }
2494
2495 #[test]
2496 fn parse_interval_hours() {
2497 let d = parse_interval("1h").unwrap();
2498 assert_eq!(d, Duration::from_secs(3600));
2499 }
2500
2501 #[test]
2502 fn parse_interval_seconds() {
2503 let d = parse_interval("30s").unwrap();
2504 assert_eq!(d, Duration::from_secs(30));
2505 }
2506
2507 #[test]
2508 fn parse_interval_compound() {
2509 let d = parse_interval("1h30m").unwrap();
2510 assert_eq!(d, Duration::from_secs(5400));
2511 }
2512
2513 #[test]
2514 fn parse_interval_empty_uses_default() {
2515 let d = parse_interval("").unwrap();
2516 assert_eq!(d, Duration::from_secs(DEFAULT_REQUEUE_SECS));
2517 }
2518
2519 #[test]
2520 fn parse_interval_bare_number_treated_as_seconds() {
2521 let d = parse_interval("120").unwrap();
2522 assert_eq!(d, Duration::from_secs(120));
2523 }
2524
2525 #[test]
2526 fn parse_interval_invalid_unit() {
2527 let result = parse_interval("5x");
2528 assert!(result.is_err());
2529 }
2530
2531 #[test]
2532 fn accumulate_summary_counts() {
2533 use pgroles_core::diff::Change;
2534 use pgroles_core::model::RoleState;
2535
2536 let mut summary = ChangeSummary::default();
2537
2538 accumulate_summary(
2539 &mut summary,
2540 &Change::CreateRole {
2541 name: "test".to_string(),
2542 state: RoleState {
2543 login: true,
2544 ..RoleState::default()
2545 },
2546 },
2547 );
2548 accumulate_summary(
2549 &mut summary,
2550 &Change::Grant {
2551 role: "test".to_string(),
2552 object_type: pgroles_core::manifest::ObjectType::Schema,
2553 schema: None,
2554 name: Some("public".to_string()),
2555 privileges: [pgroles_core::manifest::Privilege::Usage]
2556 .into_iter()
2557 .collect(),
2558 },
2559 );
2560 accumulate_summary(
2561 &mut summary,
2562 &Change::TerminateSessions {
2563 role: "test".to_string(),
2564 },
2565 );
2566
2567 assert_eq!(summary.roles_created, 1);
2568 assert_eq!(summary.grants_added, 1);
2569 assert_eq!(summary.sessions_terminated, 1);
2570 }
2571
2572 #[test]
2573 fn accumulate_summary_counts_schema_changes_separately() {
2574 use pgroles_core::diff::Change;
2575
2576 let mut summary = ChangeSummary::default();
2577
2578 accumulate_summary(
2579 &mut summary,
2580 &Change::CreateSchema {
2581 name: "inventory".to_string(),
2582 owner: Some("inventory_owner".to_string()),
2583 },
2584 );
2585 accumulate_summary(
2586 &mut summary,
2587 &Change::AlterSchemaOwner {
2588 name: "catalog".to_string(),
2589 owner: "catalog_owner".to_string(),
2590 },
2591 );
2592
2593 assert_eq!(summary.schemas_created, 1);
2594 assert_eq!(summary.schema_owners_altered, 1);
2595 assert_eq!(summary.grants_added, 0);
2596 }
2597
2598 #[test]
2599 fn summarize_changes_sets_total() {
2600 use pgroles_core::diff::Change;
2601 use pgroles_core::model::RoleState;
2602
2603 let changes = vec![
2604 Change::CreateRole {
2605 name: "test".to_string(),
2606 state: RoleState::default(),
2607 },
2608 Change::CreateSchema {
2609 name: "inventory".to_string(),
2610 owner: Some("inventory_owner".to_string()),
2611 },
2612 Change::Grant {
2613 role: "test".to_string(),
2614 object_type: pgroles_core::manifest::ObjectType::Schema,
2615 schema: None,
2616 name: Some("public".to_string()),
2617 privileges: [pgroles_core::manifest::Privilege::Usage]
2618 .into_iter()
2619 .collect(),
2620 },
2621 ];
2622
2623 let summary = summarize_changes(&changes);
2624 assert_eq!(summary.roles_created, 1);
2625 assert_eq!(summary.schemas_created, 1);
2626 assert_eq!(summary.grants_added, 1);
2627 assert_eq!(summary.total, 3);
2628 }
2629
2630 #[test]
2631 fn truncate_status_text_marks_truncation() {
2632 let text = "x".repeat(MAX_PLANNED_SQL_STATUS_BYTES + 32);
2633 let (truncated, did_truncate) = truncate_status_text(&text, MAX_PLANNED_SQL_STATUS_BYTES);
2634 assert!(did_truncate);
2635 assert!(truncated.len() <= MAX_PLANNED_SQL_STATUS_BYTES);
2636 assert!(truncated.ends_with("-- truncated for status --"));
2637 }
2638
2639 #[test]
2640 fn accumulate_summary_all_change_types() {
2641 use pgroles_core::diff::Change;
2642 use pgroles_core::model::RoleState;
2643
2644 let mut summary = ChangeSummary::default();
2645
2646 accumulate_summary(
2647 &mut summary,
2648 &Change::CreateRole {
2649 name: "r1".to_string(),
2650 state: RoleState::default(),
2651 },
2652 );
2653 accumulate_summary(
2654 &mut summary,
2655 &Change::AlterRole {
2656 name: "r1".to_string(),
2657 attributes: vec![pgroles_core::model::RoleAttribute::Login(true)],
2658 },
2659 );
2660 accumulate_summary(
2661 &mut summary,
2662 &Change::CreateSchema {
2663 name: "schema1".to_string(),
2664 owner: Some("owner1".to_string()),
2665 },
2666 );
2667 accumulate_summary(
2668 &mut summary,
2669 &Change::AlterSchemaOwner {
2670 name: "schema2".to_string(),
2671 owner: "owner2".to_string(),
2672 },
2673 );
2674 accumulate_summary(
2675 &mut summary,
2676 &Change::SetComment {
2677 name: "r1".to_string(),
2678 comment: Some("comment".to_string()),
2679 },
2680 );
2681 accumulate_summary(
2682 &mut summary,
2683 &Change::DropRole {
2684 name: "r1".to_string(),
2685 },
2686 );
2687 accumulate_summary(
2688 &mut summary,
2689 &Change::TerminateSessions {
2690 role: "r1".to_string(),
2691 },
2692 );
2693 accumulate_summary(
2694 &mut summary,
2695 &Change::ReassignOwned {
2696 from_role: "r1".to_string(),
2697 to_role: "r2".to_string(),
2698 },
2699 );
2700 accumulate_summary(
2701 &mut summary,
2702 &Change::DropOwned {
2703 role: "r1".to_string(),
2704 },
2705 );
2706 accumulate_summary(
2707 &mut summary,
2708 &Change::Grant {
2709 role: "r1".to_string(),
2710 object_type: pgroles_core::manifest::ObjectType::Table,
2711 schema: Some("public".to_string()),
2712 name: Some("*".to_string()),
2713 privileges: [pgroles_core::manifest::Privilege::Select]
2714 .into_iter()
2715 .collect(),
2716 },
2717 );
2718 accumulate_summary(
2719 &mut summary,
2720 &Change::Revoke {
2721 role: "r1".to_string(),
2722 object_type: pgroles_core::manifest::ObjectType::Table,
2723 schema: Some("public".to_string()),
2724 name: Some("*".to_string()),
2725 privileges: [pgroles_core::manifest::Privilege::Select]
2726 .into_iter()
2727 .collect(),
2728 },
2729 );
2730 accumulate_summary(
2731 &mut summary,
2732 &Change::SetDefaultPrivilege {
2733 schema: "public".to_string(),
2734 owner: "owner".to_string(),
2735 grantee: "r1".to_string(),
2736 on_type: pgroles_core::manifest::ObjectType::Table,
2737 privileges: [pgroles_core::manifest::Privilege::Select]
2738 .into_iter()
2739 .collect(),
2740 },
2741 );
2742 accumulate_summary(
2743 &mut summary,
2744 &Change::RevokeDefaultPrivilege {
2745 schema: "public".to_string(),
2746 owner: "owner".to_string(),
2747 grantee: "r1".to_string(),
2748 on_type: pgroles_core::manifest::ObjectType::Table,
2749 privileges: [pgroles_core::manifest::Privilege::Select]
2750 .into_iter()
2751 .collect(),
2752 },
2753 );
2754 accumulate_summary(
2755 &mut summary,
2756 &Change::AddMember {
2757 role: "r1".to_string(),
2758 member: "r2".to_string(),
2759 inherit: true,
2760 admin: false,
2761 },
2762 );
2763 accumulate_summary(
2764 &mut summary,
2765 &Change::RemoveMember {
2766 role: "r1".to_string(),
2767 member: "r2".to_string(),
2768 },
2769 );
2770
2771 assert_eq!(summary.roles_created, 1);
2772 assert_eq!(summary.roles_altered, 2);
2774 assert_eq!(summary.schemas_created, 1);
2775 assert_eq!(summary.schema_owners_altered, 1);
2776 assert_eq!(summary.roles_dropped, 1);
2777 assert_eq!(summary.sessions_terminated, 1);
2778 assert_eq!(summary.grants_added, 1);
2779 assert_eq!(summary.grants_revoked, 1);
2780 assert_eq!(summary.default_privileges_set, 1);
2781 assert_eq!(summary.default_privileges_revoked, 1);
2782 assert_eq!(summary.members_added, 1);
2783 assert_eq!(summary.members_removed, 1);
2784 }
2785
2786 #[test]
2787 fn error_reason_invalid_spec_for_manifest_expansion() {
2788 let err = ReconcileError::ManifestExpansion(
2789 pgroles_core::manifest::ManifestError::UndefinedProfile("bad".into(), "schema1".into()),
2790 );
2791 assert_eq!(err.reason(), "InvalidSpec");
2792 }
2793
2794 #[test]
2795 fn error_reason_invalid_spec_for_invalid_interval() {
2796 let err = ReconcileError::InvalidInterval("5x".into(), "unknown unit 'x'".into());
2797 assert_eq!(err.reason(), "InvalidSpec");
2798 }
2799
2800 #[test]
2801 fn error_reason_invalid_spec_for_password_validation() {
2802 let err = ReconcileError::InvalidSpec("role password must set exactly one mode".into());
2803 assert_eq!(err.reason(), "InvalidSpec");
2804 }
2805
2806 #[test]
2807 fn error_reason_missing_database_objects() {
2808 let err = ReconcileError::MissingDatabaseObjects("schema \"etl\"".into());
2809 assert_eq!(err.reason(), "MissingDatabaseObject");
2810 }
2811
2812 #[test]
2813 fn error_reason_unsatisfiable_wildcard_grant() {
2814 let err = ReconcileError::UnsatisfiableWildcardGrant(
2815 "UnsatisfiableWildcardGrant: function f2() is not grantable".into(),
2816 );
2817 assert_eq!(err.reason(), "UnsatisfiableWildcardGrant");
2818 assert!(err.to_string().contains("UnsatisfiableWildcardGrant"));
2819 }
2820
2821 #[test]
2822 fn unsatisfiable_wildcard_status_is_degraded_without_plan_reference() {
2823 let message = "UnsatisfiableWildcardGrant: cannot fully satisfy wildcard grant EXECUTE ON function * IN SCHEMA \"app\" TO \"reader\" as executor \"app_owner\"; 1 matching object(s) are missing the desired privilege and are not grantable (examples: \"f2()\" owned by \"definer\" missing [EXECUTE])";
2824 let mut status = PostgresPolicyStatus {
2825 conditions: vec![
2826 ready_condition(true, "Planned", "Plan computed"),
2827 conflict_condition("ConflictingPolicy", "Policy overlaps another policy"),
2828 reconciling_condition("Reconciliation in progress"),
2829 drifted_condition(true, "DriftDetected", "1 planned change pending"),
2830 ],
2831 change_summary: Some(ChangeSummary {
2832 grants_added: 1,
2833 total: 1,
2834 ..Default::default()
2835 }),
2836 planned_sql: Some(
2837 "GRANT EXECUTE ON ALL ROUTINES IN SCHEMA \"app\" TO \"reader\";".into(),
2838 ),
2839 planned_sql_truncated: true,
2840 last_error: None,
2841 transient_failure_count: 3,
2842 current_plan_ref: Some(crate::crd::PlanReference {
2843 name: "example-plan".into(),
2844 }),
2845 ..Default::default()
2846 };
2847
2848 mark_reconcile_failure_status(
2849 &mut status,
2850 "UnsatisfiableWildcardGrant",
2851 message,
2852 false,
2853 true,
2854 );
2855
2856 let ready = status
2857 .conditions
2858 .iter()
2859 .find(|condition| condition.condition_type == "Ready")
2860 .expect("Ready condition should be present");
2861 assert_eq!(ready.status, "False");
2862 assert_eq!(ready.reason.as_deref(), Some("UnsatisfiableWildcardGrant"));
2863 assert_eq!(ready.message.as_deref(), Some(message));
2864
2865 let degraded = status
2866 .conditions
2867 .iter()
2868 .find(|condition| condition.condition_type == "Degraded")
2869 .expect("Degraded condition should be present");
2870 assert_eq!(degraded.status, "True");
2871 assert_eq!(
2872 degraded.reason.as_deref(),
2873 Some("UnsatisfiableWildcardGrant")
2874 );
2875 assert_eq!(degraded.message.as_deref(), Some(message));
2876
2877 assert!(
2878 status.conditions.iter().all(|condition| {
2879 condition.condition_type != "Reconciling"
2880 && condition.condition_type != "Drifted"
2881 && condition.condition_type != "Conflict"
2882 }),
2883 "transient planning and stale conflict conditions should be cleared on degraded status"
2884 );
2885 assert!(status.change_summary.is_none());
2886 assert!(status.planned_sql.is_none());
2887 assert!(!status.planned_sql_truncated);
2888 assert!(status.current_plan_ref.is_none());
2889 assert_eq!(status.last_error.as_deref(), Some(message));
2890 assert_eq!(status.transient_failure_count, 0);
2891 }
2892
2893 #[test]
2894 fn reconcile_failure_status_preserves_plan_reference_when_requested() {
2895 let mut status = PostgresPolicyStatus {
2896 current_plan_ref: Some(crate::crd::PlanReference {
2897 name: "approved-plan".into(),
2898 }),
2899 planned_sql: Some("ALTER ROLE \"app\" LOGIN;".into()),
2900 planned_sql_truncated: true,
2901 transient_failure_count: 2,
2902 ..Default::default()
2903 };
2904
2905 mark_reconcile_failure_status(
2906 &mut status,
2907 "ApplyFailed",
2908 "SQL execution error: connection closed",
2909 true,
2910 false,
2911 );
2912
2913 assert_eq!(
2914 status
2915 .current_plan_ref
2916 .as_ref()
2917 .map(|plan| plan.name.as_str()),
2918 Some("approved-plan")
2919 );
2920 assert!(status.planned_sql.is_none());
2921 assert!(!status.planned_sql_truncated);
2922 assert_eq!(
2923 status.last_error.as_deref(),
2924 Some("SQL execution error: connection closed")
2925 );
2926 assert_eq!(status.transient_failure_count, 3);
2927 }
2928
2929 #[test]
2930 fn error_display_missing_database_objects_lists_schemas() {
2931 let err = ReconcileError::MissingDatabaseObjects("schema \"etl\", schema \"jobs\"".into());
2932 let msg = err.to_string();
2933 assert!(msg.contains("schema \"etl\""));
2934 assert!(msg.contains("schema \"jobs\""));
2935 assert!(
2936 msg.contains("pointing at the intended database"),
2937 "message should include remediation hint"
2938 );
2939 }
2940
2941 #[test]
2942 fn referenced_schema_names_from_schema_grants() {
2943 use pgroles_core::manifest::{
2944 ExpandedManifest, Grant, ObjectTarget, ObjectType, Privilege,
2945 };
2946 let expanded = ExpandedManifest {
2947 schemas: Vec::new(),
2948 roles: Vec::new(),
2949 grants: vec![Grant {
2950 role: "app".into(),
2951 privileges: vec![Privilege::Usage],
2952 object: ObjectTarget {
2953 object_type: ObjectType::Schema,
2954 schema: None,
2955 name: Some("etl".into()),
2956 },
2957 }],
2958 default_privileges: Vec::new(),
2959 memberships: Vec::new(),
2960 };
2961 let names = referenced_schema_names(&expanded);
2962 assert!(names.contains("etl"));
2963 }
2964
2965 #[test]
2966 fn referenced_schema_names_from_table_grants() {
2967 use pgroles_core::manifest::{
2968 ExpandedManifest, Grant, ObjectTarget, ObjectType, Privilege,
2969 };
2970 let expanded = ExpandedManifest {
2971 schemas: Vec::new(),
2972 roles: Vec::new(),
2973 grants: vec![Grant {
2974 role: "app".into(),
2975 privileges: vec![Privilege::Select],
2976 object: ObjectTarget {
2977 object_type: ObjectType::Table,
2978 schema: Some("analytics".into()),
2979 name: Some("*".into()),
2980 },
2981 }],
2982 default_privileges: Vec::new(),
2983 memberships: Vec::new(),
2984 };
2985 let names = referenced_schema_names(&expanded);
2986 assert!(names.contains("analytics"));
2987 }
2988
2989 #[test]
2990 fn referenced_schema_names_from_default_privileges() {
2991 use pgroles_core::manifest::{
2992 DefaultPrivilege, DefaultPrivilegeGrant, ExpandedManifest, ObjectType, Privilege,
2993 };
2994 let expanded = ExpandedManifest {
2995 schemas: Vec::new(),
2996 roles: Vec::new(),
2997 grants: Vec::new(),
2998 default_privileges: vec![DefaultPrivilege {
2999 owner: Some("app_owner".into()),
3000 schema: "reporting".into(),
3001 grant: vec![DefaultPrivilegeGrant {
3002 role: Some("app".into()),
3003 privileges: vec![Privilege::Select],
3004 on_type: ObjectType::Table,
3005 }],
3006 }],
3007 memberships: Vec::new(),
3008 };
3009 let names = referenced_schema_names(&expanded);
3010 assert!(names.contains("reporting"));
3011 }
3012
3013 #[test]
3014 fn referenced_schema_names_deduplicates_across_sources() {
3015 use pgroles_core::manifest::{
3016 DefaultPrivilege, DefaultPrivilegeGrant, ExpandedManifest, Grant, ObjectTarget,
3017 ObjectType, Privilege,
3018 };
3019 let expanded = ExpandedManifest {
3020 schemas: Vec::new(),
3021 roles: Vec::new(),
3022 grants: vec![
3023 Grant {
3024 role: "app".into(),
3025 privileges: vec![Privilege::Usage],
3026 object: ObjectTarget {
3027 object_type: ObjectType::Schema,
3028 schema: None,
3029 name: Some("shared".into()),
3030 },
3031 },
3032 Grant {
3033 role: "app".into(),
3034 privileges: vec![Privilege::Select],
3035 object: ObjectTarget {
3036 object_type: ObjectType::Table,
3037 schema: Some("shared".into()),
3038 name: Some("*".into()),
3039 },
3040 },
3041 ],
3042 default_privileges: vec![DefaultPrivilege {
3043 owner: Some("app_owner".into()),
3044 schema: "shared".into(),
3045 grant: vec![DefaultPrivilegeGrant {
3046 role: Some("app".into()),
3047 privileges: vec![Privilege::Select],
3048 on_type: ObjectType::Table,
3049 }],
3050 }],
3051 memberships: Vec::new(),
3052 };
3053 let names = referenced_schema_names(&expanded);
3054 assert_eq!(names.len(), 1);
3056 assert!(names.contains("shared"));
3057 }
3058
3059 #[test]
3060 fn referenced_schema_names_skips_database_and_roleless_grants() {
3061 use pgroles_core::manifest::{
3062 ExpandedManifest, Grant, ObjectTarget, ObjectType, Privilege,
3063 };
3064 let expanded = ExpandedManifest {
3065 schemas: Vec::new(),
3066 roles: Vec::new(),
3067 grants: vec![Grant {
3068 role: "app".into(),
3069 privileges: vec![Privilege::Connect],
3070 object: ObjectTarget {
3071 object_type: ObjectType::Database,
3072 schema: None,
3073 name: Some("mydb".into()),
3074 },
3075 }],
3076 default_privileges: Vec::new(),
3077 memberships: Vec::new(),
3078 };
3079 let names = referenced_schema_names(&expanded);
3080 assert!(
3081 names.is_empty(),
3082 "database-level grants should not contribute schema names"
3083 );
3084 }
3085
3086 #[test]
3087 fn is_system_schema_identifies_pg_and_information_schema() {
3088 assert!(is_system_schema("pg_catalog"));
3089 assert!(is_system_schema("pg_toast"));
3090 assert!(is_system_schema("pg_temp_1"));
3091 assert!(is_system_schema("information_schema"));
3092 assert!(!is_system_schema("public"));
3093 assert!(!is_system_schema("etl"));
3094 assert!(!is_system_schema("analytics"));
3095 }
3096
3097 #[test]
3098 fn referenced_schema_names_include_declared_schemas() {
3099 use pgroles_core::manifest::{ExpandedManifest, ExpandedSchema};
3100
3101 let expanded = ExpandedManifest {
3102 schemas: vec![ExpandedSchema {
3103 name: "cdc".into(),
3104 owner: Some("cdc_owner".into()),
3105 }],
3106 roles: Vec::new(),
3107 grants: Vec::new(),
3108 default_privileges: Vec::new(),
3109 memberships: Vec::new(),
3110 };
3111
3112 let names = referenced_schema_names(&expanded);
3113 assert!(names.contains("cdc"));
3114 }
3115
3116 #[test]
3117 fn declared_schema_names_returns_declared_only() {
3118 use pgroles_core::manifest::{ExpandedManifest, ExpandedSchema};
3119
3120 let expanded = ExpandedManifest {
3121 schemas: vec![ExpandedSchema {
3122 name: "cdc".into(),
3123 owner: Some("cdc_owner".into()),
3124 }],
3125 roles: Vec::new(),
3126 grants: Vec::new(),
3127 default_privileges: Vec::new(),
3128 memberships: Vec::new(),
3129 };
3130
3131 let names = declared_schema_names(&expanded);
3132 assert_eq!(names.len(), 1);
3133 assert!(names.contains("cdc"));
3134 }
3135
3136 #[test]
3137 fn externally_required_schema_names_excludes_declared_schemas() {
3138 use pgroles_core::manifest::{
3139 ExpandedManifest, ExpandedSchema, Grant, ObjectTarget, ObjectType, Privilege,
3140 };
3141
3142 let expanded = ExpandedManifest {
3143 schemas: vec![ExpandedSchema {
3144 name: "managed".into(),
3145 owner: Some("managed_owner".into()),
3146 }],
3147 roles: Vec::new(),
3148 grants: vec![
3149 Grant {
3150 role: "app".into(),
3151 privileges: vec![Privilege::Usage],
3152 object: ObjectTarget {
3153 object_type: ObjectType::Schema,
3154 schema: None,
3155 name: Some("managed".into()),
3156 },
3157 },
3158 Grant {
3159 role: "app".into(),
3160 privileges: vec![Privilege::Select],
3161 object: ObjectTarget {
3162 object_type: ObjectType::Table,
3163 schema: Some("external".into()),
3164 name: Some("*".into()),
3165 },
3166 },
3167 ],
3168 default_privileges: Vec::new(),
3169 memberships: Vec::new(),
3170 };
3171
3172 let names = externally_required_schema_names(&expanded);
3173 assert_eq!(names.len(), 1);
3174 assert!(names.contains("external"));
3175 assert!(!names.contains("managed"));
3176 }
3177
3178 #[test]
3179 fn error_reason_conflicting_policy() {
3180 let err = ReconcileError::ConflictingPolicy("overlaps with other".into());
3181 assert_eq!(err.reason(), "ConflictingPolicy");
3182 }
3183
3184 #[test]
3185 fn requested_reconcile_is_handled_only_after_successful_outcomes() {
3186 assert!(ReconcileOutcome::Reconciled.marks_requested_reconcile_handled());
3187 assert!(ReconcileOutcome::Planned.marks_requested_reconcile_handled());
3188 assert!(!ReconcileOutcome::Suspended.marks_requested_reconcile_handled());
3189 assert!(!ReconcileOutcome::Conflict.marks_requested_reconcile_handled());
3190 assert!(!ReconcileOutcome::LockContention.marks_requested_reconcile_handled());
3191 }
3192
3193 #[test]
3194 fn error_reason_unsafe_role_drops() {
3195 let err = ReconcileError::UnsafeRoleDrops("role owns objects".into());
3196 assert_eq!(err.reason(), "UnsafeRoleDrops");
3197 }
3198
3199 #[test]
3200 fn error_reason_no_namespace() {
3201 let err = ReconcileError::NoNamespace;
3202 assert_eq!(err.reason(), "InvalidResource");
3203 }
3204
3205 #[test]
3206 fn error_reason_context_secret_missing() {
3207 let err = ReconcileError::Context(Box::new(crate::context::ContextError::SecretMissing {
3208 name: "pg-secret".into(),
3209 key: "DATABASE_URL".into(),
3210 }));
3211 assert_eq!(err.reason(), "SecretMissing");
3212 }
3213
3214 #[test]
3215 fn error_reason_sql_exec_insufficient_privileges() {
3216 let err = ReconcileError::SqlExec(insufficient_privilege_sqlx_error());
3217 assert_eq!(err.reason(), "InsufficientPrivileges");
3218 }
3219
3220 #[test]
3221 fn error_reason_inspect_insufficient_privileges() {
3222 let err = ReconcileError::Inspect(pgroles_inspect::InspectError::Database(
3223 insufficient_privilege_sqlx_error(),
3224 ));
3225 assert_eq!(err.reason(), "InsufficientPrivileges");
3226 }
3227
3228 #[test]
3229 fn error_display_includes_details() {
3230 let err = ReconcileError::InvalidInterval("5x".into(), "unknown unit 'x'".into());
3231 let msg = err.to_string();
3232 assert!(msg.contains("5x"), "error display should contain interval");
3233 assert!(
3234 msg.contains("unknown unit"),
3235 "error display should contain reason"
3236 );
3237 }
3238
3239 #[test]
3240 fn error_reason_lock_contention() {
3241 let err = ReconcileError::LockContention(
3242 "prod/db-creds/DATABASE_URL".into(),
3243 "in-process lock held".into(),
3244 );
3245 assert_eq!(err.reason(), "LockContention");
3246 }
3247
3248 #[test]
3249 fn error_display_lock_contention_includes_database() {
3250 let err = ReconcileError::LockContention(
3251 "prod/db-creds/DATABASE_URL".into(),
3252 "advisory lock held by another session".into(),
3253 );
3254 let msg = err.to_string();
3255 assert!(
3256 msg.contains("prod/db-creds/DATABASE_URL"),
3257 "lock contention error should include database identity"
3258 );
3259 assert!(
3260 msg.contains("advisory lock"),
3261 "lock contention error should include reason"
3262 );
3263 }
3264
3265 #[test]
3266 fn requeue_with_jitter_produces_bounded_delay() {
3267 let base = LOCK_CONTENTION_BASE_SECS;
3269 let max = LOCK_CONTENTION_BASE_SECS + LOCK_CONTENTION_JITTER_SECS;
3270 for _ in 0..20 {
3271 let delay = jitter_delay();
3272 let secs = delay.as_secs();
3273 assert!(
3274 secs >= base,
3275 "jitter delay {secs}s should be at least base {base}s",
3276 );
3277 assert!(
3278 secs <= max,
3279 "jitter delay {secs}s should not exceed base+jitter {max}s",
3280 );
3281 }
3282 }
3283
3284 #[test]
3285 fn lock_contention_constants_are_reasonable() {
3286 let base = LOCK_CONTENTION_BASE_SECS;
3288 let jitter = LOCK_CONTENTION_JITTER_SECS;
3289 assert!(base > 0, "base delay must be positive");
3290 assert!(jitter > 0, "jitter window must be positive");
3291 assert!(
3292 base + jitter <= 60,
3293 "total max contention delay should not exceed error_policy's 60s"
3294 );
3295 }
3296
3297 #[test]
3298 fn transient_backoff_delay_is_bounded_and_caps() {
3299 for _ in 0..20 {
3300 let first = transient_backoff_delay(1).as_secs();
3301 assert!((TRANSIENT_BACKOFF_BASE_SECS..=7).contains(&first));
3302
3303 let fourth = transient_backoff_delay(4).as_secs();
3304 assert!((40..=60).contains(&fourth));
3305
3306 let capped = transient_backoff_delay(10).as_secs();
3307 assert_eq!(capped, TRANSIENT_BACKOFF_MAX_SECS);
3308 }
3309 }
3310
3311 #[test]
3312 fn slow_retry_delay_uses_policy_interval() {
3313 let resource = test_policy("7m", 0);
3314 assert_eq!(slow_retry_delay(&resource), Duration::from_secs(420));
3315 }
3316
3317 #[test]
3318 fn slow_retry_delay_falls_back_on_invalid_interval() {
3319 let resource = test_policy("nope", 0);
3320 assert_eq!(
3321 slow_retry_delay(&resource),
3322 Duration::from_secs(DEFAULT_REQUEUE_SECS)
3323 );
3324 }
3325
3326 #[test]
3327 fn retry_classifies_lock_contention_separately() {
3328 let error = finalizer::Error::ApplyFailed(ReconcileError::LockContention(
3329 "default/db-credentials/DATABASE_URL".into(),
3330 "lock held".into(),
3331 ));
3332 assert_eq!(retry_class(&error), RetryClass::LockContention);
3333 }
3334
3335 #[test]
3336 fn retry_classifies_invalid_spec_as_slow() {
3337 let error = finalizer::Error::ApplyFailed(ReconcileError::InvalidInterval(
3338 "oops".into(),
3339 "bad interval".into(),
3340 ));
3341 assert_eq!(retry_class(&error), RetryClass::Slow);
3342 }
3343
3344 #[test]
3345 fn retry_classifies_missing_database_objects_as_slow() {
3346 let error = finalizer::Error::ApplyFailed(ReconcileError::MissingDatabaseObjects(
3347 "schema \"etl\"".into(),
3348 ));
3349 assert_eq!(retry_class(&error), RetryClass::Slow);
3350 }
3351
3352 #[test]
3353 fn retry_classifies_unsatisfiable_wildcard_grant_as_slow() {
3354 let error = finalizer::Error::ApplyFailed(ReconcileError::UnsatisfiableWildcardGrant(
3355 "UnsatisfiableWildcardGrant: function f2() is not grantable".into(),
3356 ));
3357 assert_eq!(retry_class(&error), RetryClass::Slow);
3358 }
3359
3360 #[test]
3361 fn retry_classifies_plan_sql_storage_as_slow() {
3362 let error =
3363 finalizer::Error::ApplyFailed(ReconcileError::PlanSqlStorage("gzip failed".into()));
3364 assert_eq!(retry_class(&error), RetryClass::Slow);
3365 }
3366
3367 #[test]
3368 fn retry_classifies_secret_missing_as_slow() {
3369 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3370 crate::context::ContextError::SecretMissing {
3371 name: "db-credentials".into(),
3372 key: "DATABASE_URL".into(),
3373 },
3374 )));
3375 assert_eq!(retry_class(&error), RetryClass::Slow);
3376 }
3377
3378 #[test]
3379 fn retry_classifies_secret_fetch_not_found_as_slow() {
3380 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3381 crate::context::ContextError::SecretFetch {
3382 name: "db-credentials".into(),
3383 namespace: "default".into(),
3384 source: kube::Error::Api(
3385 kube::core::Status::failure("secrets \"db-credentials\" not found", "NotFound")
3386 .with_code(404)
3387 .boxed(),
3388 ),
3389 },
3390 )));
3391 assert_eq!(retry_class(&error), RetryClass::Slow);
3392 }
3393
3394 #[test]
3395 fn retry_classifies_secret_fetch_transport_errors_as_transient() {
3396 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3397 crate::context::ContextError::SecretFetch {
3398 name: "db-credentials".into(),
3399 namespace: "default".into(),
3400 source: kube::Error::Api(
3401 kube::core::Status::failure("internal error", "InternalError")
3402 .with_code(500)
3403 .boxed(),
3404 ),
3405 },
3406 )));
3407 assert_eq!(retry_class(&error), RetryClass::Transient);
3408 }
3409
3410 #[test]
3411 fn retry_classifies_secret_fetch_forbidden_as_slow() {
3412 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3413 crate::context::ContextError::SecretFetch {
3414 name: "db-credentials".into(),
3415 namespace: "default".into(),
3416 source: kube::Error::Api(
3417 kube::core::Status::failure("forbidden", "Forbidden")
3418 .with_code(403)
3419 .boxed(),
3420 ),
3421 },
3422 )));
3423 assert_eq!(retry_class(&error), RetryClass::Slow);
3424 }
3425
3426 #[test]
3427 fn retry_classifies_database_connect_as_transient() {
3428 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3429 crate::context::ContextError::DatabaseConnect {
3430 source: sqlx::Error::PoolTimedOut,
3431 },
3432 )));
3433 assert_eq!(retry_class(&error), RetryClass::Transient);
3434 }
3435
3436 #[test]
3437 fn retry_classifies_set_role_failed_as_slow() {
3438 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3439 crate::context::ContextError::SetRoleFailed {
3440 role: "cloudsqlsuperuser".to_string(),
3441 source: sqlx::Error::Protocol("permission denied".to_string()),
3442 },
3443 )));
3444 assert_eq!(retry_class(&error), RetryClass::Slow);
3445 }
3446
3447 #[test]
3448 fn retry_classifies_sql_exec_insufficient_privilege_as_slow() {
3449 let error = finalizer::Error::ApplyFailed(ReconcileError::SqlExec(
3450 insufficient_privilege_sqlx_error(),
3451 ));
3452 assert_eq!(retry_class(&error), RetryClass::Slow);
3453 }
3454
3455 #[test]
3456 fn retry_classifies_inspect_insufficient_privilege_as_slow() {
3457 let error = finalizer::Error::ApplyFailed(ReconcileError::Inspect(
3458 pgroles_inspect::InspectError::Database(insufficient_privilege_sqlx_error()),
3459 ));
3460 assert_eq!(retry_class(&error), RetryClass::Slow);
3461 }
3462
3463 #[test]
3464 fn classify_sqlx_error_categories() {
3465 assert_eq!(
3466 classify_sqlx_error(&insufficient_privilege_sqlx_error()),
3467 SqlErrorKind::InsufficientPrivileges
3468 );
3469 assert_eq!(
3470 classify_sqlx_error(&missing_schema_sqlx_error()),
3471 SqlErrorKind::MissingDatabaseObject
3472 );
3473 assert_eq!(
3474 classify_sqlx_error(&missing_table_sqlx_error()),
3475 SqlErrorKind::MissingDatabaseObject
3476 );
3477 assert_eq!(
3478 classify_sqlx_error(&missing_function_sqlx_error()),
3479 SqlErrorKind::MissingDatabaseObject
3480 );
3481 assert_eq!(
3482 classify_sqlx_error(&missing_object_sqlx_error()),
3483 SqlErrorKind::MissingDatabaseObject
3484 );
3485 assert_eq!(
3486 classify_sqlx_error(&transient_sqlx_error()),
3487 SqlErrorKind::Transient
3488 );
3489 }
3490
3491 #[test]
3492 fn retry_classifies_sql_exec_missing_schema_as_slow() {
3493 let error =
3494 finalizer::Error::ApplyFailed(ReconcileError::SqlExec(missing_schema_sqlx_error()));
3495 assert_eq!(retry_class(&error), RetryClass::Slow);
3496 }
3497
3498 #[test]
3499 fn retry_classifies_sql_exec_missing_table_as_slow() {
3500 let error =
3501 finalizer::Error::ApplyFailed(ReconcileError::SqlExec(missing_table_sqlx_error()));
3502 assert_eq!(retry_class(&error), RetryClass::Slow);
3503 }
3504
3505 #[test]
3506 fn retry_classifies_inspect_missing_schema_as_slow() {
3507 let error = finalizer::Error::ApplyFailed(ReconcileError::Inspect(
3508 pgroles_inspect::InspectError::Database(missing_schema_sqlx_error()),
3509 ));
3510 assert_eq!(retry_class(&error), RetryClass::Slow);
3511 }
3512
3513 #[test]
3514 fn error_reason_sql_exec_missing_database_object() {
3515 let err = ReconcileError::SqlExec(missing_schema_sqlx_error());
3516 assert_eq!(err.reason(), "MissingDatabaseObject");
3517 }
3518
3519 #[test]
3520 fn error_reason_inspect_missing_database_object() {
3521 let err = ReconcileError::Inspect(pgroles_inspect::InspectError::Database(
3522 missing_table_sqlx_error(),
3523 ));
3524 assert_eq!(err.reason(), "MissingDatabaseObject");
3525 }
3526
3527 #[test]
3528 fn retry_classifies_empty_resolved_value_as_slow() {
3529 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3530 crate::context::ContextError::EmptyResolvedValue {
3531 field: "password".to_string(),
3532 },
3533 )));
3534 assert_eq!(retry_class(&error), RetryClass::Slow);
3535 }
3536
3537 #[test]
3538 fn error_reason_empty_resolved_value() {
3539 let err =
3540 ReconcileError::Context(Box::new(crate::context::ContextError::EmptyResolvedValue {
3541 field: "host".to_string(),
3542 }));
3543 assert_eq!(err.reason(), "InvalidConnectionParams");
3544 }
3545
3546 #[test]
3547 fn retry_classifies_invalid_resolved_ssl_mode_as_slow() {
3548 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3549 crate::context::ContextError::InvalidResolvedSslMode {
3550 value: "bogus".to_string(),
3551 },
3552 )));
3553 assert_eq!(retry_class(&error), RetryClass::Slow);
3554 }
3555
3556 #[test]
3557 fn error_reason_invalid_resolved_ssl_mode() {
3558 let err = ReconcileError::Context(Box::new(
3559 crate::context::ContextError::InvalidResolvedSslMode {
3560 value: "bogus".to_string(),
3561 },
3562 ));
3563 assert_eq!(err.reason(), "InvalidConnectionParams");
3564 }
3565
3566 #[test]
3567 fn retry_classifies_gcp_auth_permission_error_as_slow() {
3568 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3569 crate::context::ContextError::GcpAuthRejected {
3570 endpoint: "metadata".to_string(),
3571 status: 403,
3572 body: "forbidden".to_string(),
3573 },
3574 )));
3575 assert_eq!(retry_class(&error), RetryClass::Slow);
3576 }
3577
3578 #[tokio::test]
3579 async fn retry_classifies_gcp_auth_http_error_as_transient() {
3580 let source = reqwest::Client::new()
3581 .get("http://")
3582 .send()
3583 .await
3584 .expect_err("invalid URL should produce a reqwest error");
3585 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3586 crate::context::ContextError::GcpAuthHttp {
3587 endpoint: "metadata",
3588 source,
3589 },
3590 )));
3591 assert_eq!(retry_class(&error), RetryClass::Transient);
3592 }
3593
3594 #[test]
3595 fn error_reason_gcp_auth_failure() {
3596 let err =
3597 ReconcileError::Context(Box::new(crate::context::ContextError::GcpAuthRejected {
3598 endpoint: "metadata".to_string(),
3599 status: 403,
3600 body: "forbidden".to_string(),
3601 }));
3602 assert_eq!(err.reason(), "GcpAuthFailed");
3603 }
3604
3605 #[test]
3606 fn error_reason_sql_exec_transient_is_apply_failed() {
3607 let err = ReconcileError::SqlExec(transient_sqlx_error());
3608 assert_eq!(err.reason(), "ApplyFailed");
3609 }
3610
3611 #[test]
3612 fn error_reason_plan_sql_storage_failed() {
3613 let err = ReconcileError::PlanSqlStorage("gzip failed".into());
3614 assert_eq!(err.reason(), "PlanSqlStorageFailed");
3615 }
3616
3617 #[test]
3618 fn error_policy_uses_normal_interval_for_invalid_spec() {
3619 let resource = test_policy("11m", 0);
3620 let error = finalizer::Error::ApplyFailed(ReconcileError::InvalidInterval(
3621 "oops".into(),
3622 "bad interval".into(),
3623 ));
3624 assert_eq!(
3625 retry_action(&resource, &error),
3626 Action::requeue(Duration::from_secs(660))
3627 );
3628 }
3629
3630 #[test]
3631 fn error_policy_uses_exponential_backoff_for_transient_failures() {
3632 let resource = test_policy("5m", 3);
3633 let error = finalizer::Error::ApplyFailed(ReconcileError::Context(Box::new(
3634 crate::context::ContextError::DatabaseConnect {
3635 source: sqlx::Error::PoolTimedOut,
3636 },
3637 )));
3638 let action = retry_action(&resource, &error);
3639 assert!(
3640 (40..=60).any(|secs| action == Action::requeue(Duration::from_secs(secs))),
3641 "expected transient retry between 40s and 60s, got {action:?}"
3642 );
3643 }
3644
3645 #[test]
3646 fn render_plan_sql_for_status_redacts_passwords() {
3647 let changes = vec![
3648 pgroles_core::diff::Change::CreateRole {
3649 name: "app-svc".to_string(),
3650 state: pgroles_core::model::RoleState {
3651 login: true,
3652 ..pgroles_core::model::RoleState::default()
3653 },
3654 },
3655 pgroles_core::diff::Change::SetPassword {
3656 name: "app-svc".to_string(),
3657 password: "super_secret_p@ssw0rd!".to_string(),
3658 },
3659 ];
3660
3661 let sql_ctx = pgroles_core::sql::SqlContext::default();
3662 let (sql, truncated) = render_plan_sql_for_status(&changes, &sql_ctx);
3663
3664 let sql = sql.expect("expected non-empty planned SQL");
3665 assert!(!truncated);
3666 assert!(
3667 sql.contains("[REDACTED]"),
3668 "status SQL should contain [REDACTED], got: {sql}"
3669 );
3670 assert!(
3671 !sql.contains("super_secret_p@ssw0rd!"),
3672 "status SQL must NOT contain the actual password, got: {sql}"
3673 );
3674 assert!(
3675 sql.contains("CREATE ROLE"),
3676 "status SQL should still contain non-password changes, got: {sql}"
3677 );
3678 }
3679
3680 #[test]
3681 fn render_plan_sql_for_status_empty_changes_returns_none() {
3682 let sql_ctx = pgroles_core::sql::SqlContext::default();
3683 let (sql, truncated) = render_plan_sql_for_status(&[], &sql_ctx);
3684 assert!(sql.is_none());
3685 assert!(!truncated);
3686 }
3687
3688 #[test]
3689 fn render_plan_sql_for_status_password_only_plan() {
3690 let changes = vec![pgroles_core::diff::Change::SetPassword {
3691 name: "db-user".to_string(),
3692 password: "my_secret_pw".to_string(),
3693 }];
3694
3695 let sql_ctx = pgroles_core::sql::SqlContext::default();
3696 let (sql, _) = render_plan_sql_for_status(&changes, &sql_ctx);
3697
3698 let sql = sql.expect("expected non-empty planned SQL");
3699 assert!(
3700 sql.contains("[REDACTED]"),
3701 "password-only plan should still show redacted SQL"
3702 );
3703 assert!(
3704 !sql.contains("my_secret_pw"),
3705 "password-only plan must NOT leak the password"
3706 );
3707 }
3708
3709 #[test]
3710 fn error_reason_empty_password_secret() {
3711 let err = ReconcileError::EmptyPasswordSecret {
3712 role: "app-svc".to_string(),
3713 secret: "pg-passwords".to_string(),
3714 key: "app-svc".to_string(),
3715 };
3716 assert_eq!(err.reason(), "InvalidSpec");
3717 }
3718
3719 #[test]
3720 fn retry_classifies_empty_password_secret_as_slow() {
3721 let error = finalizer::Error::ApplyFailed(ReconcileError::EmptyPasswordSecret {
3722 role: "app-svc".to_string(),
3723 secret: "pg-passwords".to_string(),
3724 key: "app-svc".to_string(),
3725 });
3726 assert_eq!(retry_class(&error), RetryClass::Slow);
3727 }
3728
3729 #[test]
3730 fn error_reason_password_generation() {
3731 let err = ReconcileError::PasswordGeneration(Box::new(
3732 crate::password::PasswordError::MissingKey {
3733 secret: "my-secret".to_string(),
3734 key: "password".to_string(),
3735 },
3736 ));
3737 assert_eq!(err.reason(), "SecretFetchFailed");
3738 }
3739
3740 #[test]
3741 fn retry_classifies_password_generation_missing_key_as_slow() {
3742 let error = finalizer::Error::ApplyFailed(ReconcileError::PasswordGeneration(Box::new(
3743 crate::password::PasswordError::MissingKey {
3744 secret: "my-secret".to_string(),
3745 key: "password".to_string(),
3746 },
3747 )));
3748 assert_eq!(retry_class(&error), RetryClass::Slow);
3749 }
3750
3751 #[test]
3752 fn retry_classifies_password_generation_kube_server_error_as_transient() {
3753 let error = finalizer::Error::ApplyFailed(ReconcileError::PasswordGeneration(Box::new(
3754 crate::password::PasswordError::KubeApi {
3755 secret: "my-secret".to_string(),
3756 source: Box::new(kube::Error::Api(
3757 kube::core::Status::failure("internal error", "InternalError")
3758 .with_code(500)
3759 .boxed(),
3760 )),
3761 },
3762 )));
3763 assert_eq!(retry_class(&error), RetryClass::Transient);
3764 }
3765
3766 #[test]
3767 fn retry_classifies_password_generation_kube_forbidden_as_slow() {
3768 let error = finalizer::Error::ApplyFailed(ReconcileError::PasswordGeneration(Box::new(
3769 crate::password::PasswordError::KubeApi {
3770 secret: "my-secret".to_string(),
3771 source: Box::new(kube::Error::Api(
3772 kube::core::Status::failure("forbidden", "Forbidden")
3773 .with_code(403)
3774 .boxed(),
3775 )),
3776 },
3777 )));
3778 assert_eq!(retry_class(&error), RetryClass::Slow);
3779 }
3780
3781 #[test]
3782 fn accumulate_summary_counts_passwords() {
3783 use pgroles_core::diff::Change;
3784
3785 let mut summary = ChangeSummary::default();
3786 accumulate_summary(
3787 &mut summary,
3788 &Change::SetPassword {
3789 name: "app-svc".to_string(),
3790 password: "secret".to_string(),
3791 },
3792 );
3793 assert_eq!(summary.passwords_set, 1);
3794 }
3795
3796 #[test]
3797 fn conflict_detection_ignores_invalid_peer_policies() {
3798 let resource = valid_role_policy("valid-policy", "analytics", "shared-db-secret");
3799 let identity = DatabaseIdentity::from_connection("default", &resource.spec.connection);
3800 let ownership = resource.spec.ownership_claims().unwrap();
3801 let invalid_peer = invalid_profile_policy("invalid-peer", "shared-db-secret");
3802
3803 let conflict =
3804 detect_policy_conflict_in_list(&resource, &identity, &ownership, vec![invalid_peer]);
3805
3806 assert_eq!(conflict, None);
3807 }
3808
3809 #[test]
3810 fn resolve_passwords_from_cached_secrets_supports_default_and_explicit_keys() {
3811 let resource = password_role_policy();
3812 let cache = BTreeMap::from([(
3813 "role-passwords".to_string(),
3814 secret_with_keys(
3815 "role-passwords",
3816 &[
3817 ("app", "app-secret"),
3818 ("reporter-password", "reporter-secret"),
3819 ],
3820 ),
3821 )]);
3822
3823 let resolved =
3824 resolve_passwords_from_cached_secrets(&resource, &cache).expect("should resolve");
3825
3826 assert_eq!(
3827 resolved
3828 .get("app")
3829 .map(|password| password.cleartext.as_str()),
3830 Some("app-secret")
3831 );
3832 assert_eq!(
3833 resolved
3834 .get("reporter")
3835 .map(|password| password.cleartext.as_str()),
3836 Some("reporter-secret")
3837 );
3838 }
3839
3840 #[test]
3841 fn resolve_passwords_from_cached_secrets_skips_external_roles() {
3842 let mut resource = password_role_policy();
3843 resource.spec.roles[1].external = true;
3844 let cache = BTreeMap::from([(
3845 "role-passwords".to_string(),
3846 secret_with_keys("role-passwords", &[("app", "app-secret")]),
3847 )]);
3848
3849 let resolved =
3850 resolve_passwords_from_cached_secrets(&resource, &cache).expect("should resolve");
3851
3852 assert_eq!(
3853 resolved
3854 .get("app")
3855 .map(|password| password.cleartext.as_str()),
3856 Some("app-secret")
3857 );
3858 assert!(!resolved.contains_key("reporter"));
3859 }
3860
3861 #[test]
3862 fn resolve_passwords_from_cached_secrets_reports_missing_key() {
3863 let resource = password_role_policy();
3864 let cache = BTreeMap::from([(
3865 "role-passwords".to_string(),
3866 secret_with_keys("role-passwords", &[("app", "app-secret")]),
3867 )]);
3868
3869 let err = resolve_passwords_from_cached_secrets(&resource, &cache).unwrap_err();
3870 let context = match err {
3871 ReconcileError::Context(context) => context,
3872 other => panic!("expected context error, got {other:?}"),
3873 };
3874 assert!(matches!(
3875 *context,
3876 crate::context::ContextError::SecretMissing { ref name, ref key }
3877 if name == "role-passwords" && key == "reporter-password"
3878 ));
3879 }
3880
3881 #[test]
3882 fn resolve_passwords_from_cached_secrets_reports_empty_password() {
3883 let resource = password_role_policy();
3884 let cache = BTreeMap::from([(
3885 "role-passwords".to_string(),
3886 secret_with_keys(
3887 "role-passwords",
3888 &[("app", ""), ("reporter-password", "ok")],
3889 ),
3890 )]);
3891
3892 let err = resolve_passwords_from_cached_secrets(&resource, &cache).unwrap_err();
3893 assert!(matches!(
3894 err,
3895 ReconcileError::EmptyPasswordSecret { ref role, ref secret, ref key }
3896 if role == "app" && secret == "role-passwords" && key == "app"
3897 ));
3898 }
3899
3900 #[test]
3901 fn resolve_passwords_from_cached_secrets_allows_whitespace_passwords() {
3902 let resource = password_role_policy();
3903 let cache = BTreeMap::from([(
3904 "role-passwords".to_string(),
3905 secret_with_keys(
3906 "role-passwords",
3907 &[("app", " "), ("reporter-password", "\tsecret")],
3908 ),
3909 )]);
3910
3911 let resolved =
3912 resolve_passwords_from_cached_secrets(&resource, &cache).expect("should resolve");
3913
3914 assert_eq!(
3915 resolved
3916 .get("app")
3917 .map(|password| password.cleartext.as_str()),
3918 Some(" ")
3919 );
3920 assert_eq!(
3921 resolved
3922 .get("reporter")
3923 .map(|password| password.cleartext.as_str()),
3924 Some("\tsecret")
3925 );
3926 }
3927
3928 #[test]
3929 fn select_password_changes_skips_unchanged_password_sources() {
3930 let resolved = BTreeMap::from([(
3931 "app".to_string(),
3932 ResolvedPassword {
3933 cleartext: "app-secret".to_string(),
3934 source_version: "role-passwords:app:7".to_string(),
3935 },
3936 )]);
3937 let status = PostgresPolicyStatus {
3938 applied_password_source_versions: BTreeMap::from([(
3939 "app".to_string(),
3940 "role-passwords:app:7".to_string(),
3941 )]),
3942 ..Default::default()
3943 };
3944
3945 let (password_changes, current_versions) =
3946 select_password_changes(&[], &resolved, Some(&status));
3947
3948 assert!(password_changes.is_empty());
3949 assert_eq!(
3950 current_versions.get("app").map(String::as_str),
3951 Some("role-passwords:app:7")
3952 );
3953 }
3954
3955 #[test]
3956 fn select_password_changes_applies_on_source_version_change() {
3957 let resolved = BTreeMap::from([(
3958 "app".to_string(),
3959 ResolvedPassword {
3960 cleartext: "new-secret".to_string(),
3961 source_version: "role-passwords:app:8".to_string(),
3962 },
3963 )]);
3964 let status = PostgresPolicyStatus {
3965 applied_password_source_versions: BTreeMap::from([(
3966 "app".to_string(),
3967 "role-passwords:app:7".to_string(),
3968 )]),
3969 ..Default::default()
3970 };
3971
3972 let (password_changes, _) = select_password_changes(&[], &resolved, Some(&status));
3973
3974 assert_eq!(
3975 password_changes.get("app").map(String::as_str),
3976 Some("new-secret")
3977 );
3978 }
3979
3980 #[test]
3981 fn select_password_changes_applies_for_newly_created_role() {
3982 use pgroles_core::diff::Change;
3983 use pgroles_core::model::RoleState;
3984
3985 let resolved = BTreeMap::from([(
3986 "app".to_string(),
3987 ResolvedPassword {
3988 cleartext: "new-secret".to_string(),
3989 source_version: "role-passwords:app:7".to_string(),
3990 },
3991 )]);
3992 let status = PostgresPolicyStatus {
3993 applied_password_source_versions: BTreeMap::from([(
3994 "app".to_string(),
3995 "role-passwords:app:7".to_string(),
3996 )]),
3997 ..Default::default()
3998 };
3999 let changes = vec![Change::CreateRole {
4000 name: "app".to_string(),
4001 state: RoleState {
4002 login: true,
4003 ..RoleState::default()
4004 },
4005 }];
4006
4007 let (password_changes, _) = select_password_changes(&changes, &resolved, Some(&status));
4008
4009 assert_eq!(
4010 password_changes.get("app").map(String::as_str),
4011 Some("new-secret")
4012 );
4013 }
4014
4015 #[test]
4016 fn select_password_changes_applies_all_on_first_reconcile() {
4017 let resolved = BTreeMap::from([
4020 (
4021 "app".to_string(),
4022 ResolvedPassword {
4023 cleartext: "secret-a".to_string(),
4024 source_version: "role-passwords:app:1".to_string(),
4025 },
4026 ),
4027 (
4028 "reporter".to_string(),
4029 ResolvedPassword {
4030 cleartext: "secret-b".to_string(),
4031 source_version: "role-passwords:reporter:1".to_string(),
4032 },
4033 ),
4034 ]);
4035 let changes: Vec<pgroles_core::diff::Change> = vec![];
4036
4037 let (password_changes, versions) = select_password_changes(&changes, &resolved, None);
4038
4039 assert_eq!(
4040 password_changes.len(),
4041 2,
4042 "all passwords should be applied on first reconcile"
4043 );
4044 assert_eq!(
4045 password_changes.get("app").map(String::as_str),
4046 Some("secret-a")
4047 );
4048 assert_eq!(
4049 password_changes.get("reporter").map(String::as_str),
4050 Some("secret-b")
4051 );
4052 assert_eq!(versions.len(), 2, "all source versions should be tracked");
4053 }
4054
4055 #[test]
4056 fn conflict_detection_still_reports_overlapping_valid_peers() {
4057 let resource = valid_role_policy("valid-policy", "analytics", "shared-db-secret");
4058 let identity = DatabaseIdentity::from_connection("default", &resource.spec.connection);
4059 let ownership = resource.spec.ownership_claims().unwrap();
4060 let overlapping_peer =
4061 valid_role_policy("overlapping-peer", "analytics", "shared-db-secret");
4062 let invalid_peer = invalid_profile_policy("invalid-peer", "shared-db-secret");
4063
4064 let conflict = detect_policy_conflict_in_list(
4065 &resource,
4066 &identity,
4067 &ownership,
4068 vec![invalid_peer, overlapping_peer],
4069 );
4070
4071 let conflict = conflict.expect("expected overlapping peer to be reported");
4072 assert!(conflict.contains("overlapping-peer"));
4073 assert!(conflict.contains("roles: analytics"));
4074 }
4075
4076 #[test]
4077 fn parse_rfc3339_to_epoch_secs_known_timestamp() {
4078 let result = parse_rfc3339_to_epoch_secs("2024-01-01T00:00:00Z");
4080 assert_eq!(result, Some(1704067200));
4081 }
4082
4083 #[test]
4084 fn parse_rfc3339_to_epoch_secs_with_time() {
4085 let result = parse_rfc3339_to_epoch_secs("2024-01-01T12:30:45Z");
4087 assert_eq!(result, Some(1704112245));
4088 }
4089
4090 #[test]
4091 fn parse_rfc3339_to_epoch_secs_invalid_returns_none() {
4092 assert_eq!(parse_rfc3339_to_epoch_secs("not-a-date"), None);
4093 assert_eq!(parse_rfc3339_to_epoch_secs(""), None);
4094 }
4095
4096 #[test]
4097 fn parse_rfc3339_roundtrips_with_now_rfc3339() {
4098 let timestamp = crate::crd::now_rfc3339();
4099 let parsed = parse_rfc3339_to_epoch_secs(×tamp);
4100 assert!(parsed.is_some(), "should parse our own timestamps");
4101 let now_secs = std::time::SystemTime::now()
4102 .duration_since(std::time::UNIX_EPOCH)
4103 .unwrap()
4104 .as_secs();
4105 let diff = now_secs.abs_diff(parsed.unwrap());
4107 assert!(diff <= 2, "parsed time should be close to now, diff={diff}");
4108 }
4109}