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