Skip to main content

pgroles_operator/
context.rs

1//! Shared operator context — database pool cache, metrics, and configuration.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, SystemTime};
6
7use futures::future::{BoxFuture, FutureExt};
8use kube::runtime::events::Recorder;
9use serde::{Deserialize, Serialize};
10
11use sqlx::postgres::{PgPool, PgPoolOptions};
12use tokio::sync::{Mutex, RwLock};
13
14use crate::crd::{ConnectionAuth, ConnectionSpec, SecretKeySelector};
15use crate::observability::OperatorObservability;
16
17/// Minimum pool size required for reconciliation.
18///
19/// One connection is held for the session-scoped advisory lock while the
20/// reconcile loop performs inspection and apply work on the pool.
21const POOL_MAX_CONNECTIONS: u32 = 5;
22
23/// Bound how long a reconcile waits for a pooled connection before surfacing
24/// a transient database connectivity failure.
25const POOL_ACQUIRE_TIMEOUT_SECS: u64 = 10;
26
27const _: () = assert!(POOL_MAX_CONNECTIONS >= 2);
28
29const GCP_METADATA_TOKEN_ENDPOINT: &str =
30    "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
31const GCP_IAM_CREDENTIALS_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
32const GCP_TOKEN_CACHE_SKEW_SECS: u64 = 300;
33const GCP_IMPERSONATED_TOKEN_LIFETIME_SECS: u64 = 3600;
34const GCP_AUTH_HTTP_TIMEOUT_SECS: u64 = 10;
35
36#[derive(Clone)]
37struct CachedPool {
38    resource_version: Option<String>,
39    /// Fingerprint of all referenced secrets' resourceVersions (params mode).
40    secret_fingerprint: Option<String>,
41    /// Expiry for token-backed connection passwords.
42    token_expires_at: Option<SystemTime>,
43    pool: PgPool,
44}
45
46struct ResolvedConnectionUrl {
47    database_url: String,
48    token_expires_at: Option<SystemTime>,
49    /// Optional role to `SET ROLE` to on every pooled connection. The value
50    /// has already passed CRD-level identifier validation; the after-connect
51    /// hook re-quotes defensively before interpolating it into SQL.
52    set_role: Option<String>,
53}
54
55/// Build the `SET ROLE` SQL statement for the given identifier.
56///
57/// `SET ROLE` does not accept bind parameters, so the value is interpolated.
58/// CRD admission already restricts the identifier to the
59/// [`crate::crd::SET_ROLE_PATTERN`] regex; the embedded `"` doubling here is
60/// defence in depth for the connection-pool path.
61pub fn build_set_role_stmt(role: &str) -> String {
62    format!("SET ROLE \"{}\"", role.replace('"', "\"\""))
63}
64
65/// Marker prefix used to flag SET-ROLE failures from the `after_connect`
66/// hook so they can be distinguished from other connect-time errors.
67const SET_ROLE_FAILURE_MARKER: &str = "pgroles:set-role-failed:";
68
69/// Wrap a SET-ROLE failure in [`sqlx::Error::Protocol`] with a marker so
70/// the outer `connect()` error can be classified as
71/// [`ContextError::SetRoleFailed`] instead of a generic database connect
72/// failure.
73fn wrap_set_role_failure(role: &str, source: sqlx::Error) -> sqlx::Error {
74    sqlx::Error::Protocol(format!("{SET_ROLE_FAILURE_MARKER}{role}: {source}"))
75}
76
77/// Classify a pool-level connect error, surfacing SET-ROLE hook failures
78/// distinctly from genuine database-connect failures.
79fn classify_pool_connect_error(set_role: Option<&str>, err: sqlx::Error) -> ContextError {
80    if let (Some(role), sqlx::Error::Protocol(msg)) = (set_role, &err)
81        && msg.starts_with(SET_ROLE_FAILURE_MARKER)
82    {
83        return ContextError::SetRoleFailed {
84            role: role.to_string(),
85            source: err,
86        };
87    }
88    ContextError::DatabaseConnect { source: err }
89}
90
91#[derive(Clone)]
92struct GcpAccessToken {
93    token: String,
94    expires_at: SystemTime,
95}
96
97trait GcpAccessTokenProvider: Send + Sync {
98    fn fetch_token<'a>(
99        &'a self,
100        auth: &'a ConnectionAuth,
101    ) -> BoxFuture<'a, Result<GcpAccessToken, ContextError>>;
102}
103
104#[derive(Clone)]
105struct MetadataGcpAccessTokenProvider {
106    client: reqwest::Client,
107}
108
109impl Default for MetadataGcpAccessTokenProvider {
110    fn default() -> Self {
111        Self {
112            client: reqwest::Client::builder()
113                .no_proxy()
114                .timeout(Duration::from_secs(GCP_AUTH_HTTP_TIMEOUT_SECS))
115                .build()
116                .expect("GCP auth HTTP client should build"),
117        }
118    }
119}
120
121impl GcpAccessTokenProvider for MetadataGcpAccessTokenProvider {
122    fn fetch_token<'a>(
123        &'a self,
124        auth: &'a ConnectionAuth,
125    ) -> BoxFuture<'a, Result<GcpAccessToken, ContextError>> {
126        async move {
127            let scope = auth.gcp_scope();
128            if let Some(target) = auth.gcp_impersonate_service_account() {
129                self.fetch_impersonated_access_token(target, scope).await
130            } else {
131                self.fetch_metadata_access_token(scope).await
132            }
133        }
134        .boxed()
135    }
136}
137
138impl MetadataGcpAccessTokenProvider {
139    async fn fetch_metadata_access_token(
140        &self,
141        scope: &str,
142    ) -> Result<GcpAccessToken, ContextError> {
143        let response = self
144            .client
145            .get(GCP_METADATA_TOKEN_ENDPOINT)
146            .header("Metadata-Flavor", "Google")
147            .query(&[("scopes", scope)])
148            .send()
149            .await
150            .map_err(|source| ContextError::GcpAuthHttp {
151                endpoint: "metadata",
152                source,
153            })?;
154
155        let status = response.status();
156        if !status.is_success() {
157            let body = response_body_for_error(response).await;
158            return Err(ContextError::GcpAuthRejected {
159                endpoint: "metadata".to_string(),
160                status: status.as_u16(),
161                body,
162            });
163        }
164
165        let body: MetadataTokenResponse =
166            response
167                .json()
168                .await
169                .map_err(|source| ContextError::GcpAuthHttp {
170                    endpoint: "metadata",
171                    source,
172                })?;
173
174        if body.access_token.trim().is_empty() {
175            return Err(ContextError::GcpAuthInvalidResponse {
176                detail: "metadata token response omitted access_token".to_string(),
177            });
178        }
179        if body.expires_in == 0 {
180            return Err(ContextError::GcpAuthInvalidResponse {
181                detail: "metadata token response had zero expires_in".to_string(),
182            });
183        }
184
185        Ok(GcpAccessToken {
186            token: body.access_token,
187            expires_at: SystemTime::now() + Duration::from_secs(body.expires_in),
188        })
189    }
190
191    async fn fetch_impersonated_access_token(
192        &self,
193        target_service_account: &str,
194        scope: &str,
195    ) -> Result<GcpAccessToken, ContextError> {
196        let source = self
197            .fetch_metadata_access_token(GCP_IAM_CREDENTIALS_SCOPE)
198            .await?;
199        let encoded_target = percent_encoding::utf8_percent_encode(
200            target_service_account,
201            percent_encoding::NON_ALPHANUMERIC,
202        )
203        .to_string();
204        let endpoint = format!(
205            "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{encoded_target}:generateAccessToken"
206        );
207        let request = GenerateAccessTokenRequest {
208            scope: vec![scope.to_string()],
209            lifetime: format!("{GCP_IMPERSONATED_TOKEN_LIFETIME_SECS}s"),
210        };
211
212        let response = self
213            .client
214            .post(&endpoint)
215            .bearer_auth(&source.token)
216            .json(&request)
217            .send()
218            .await
219            .map_err(|source| ContextError::GcpAuthHttp {
220                endpoint: "iamcredentials",
221                source,
222            })?;
223
224        let status = response.status();
225        if !status.is_success() {
226            let body = response_body_for_error(response).await;
227            return Err(ContextError::GcpAuthRejected {
228                endpoint: "iamcredentials".to_string(),
229                status: status.as_u16(),
230                body,
231            });
232        }
233
234        let body: GenerateAccessTokenResponse =
235            response
236                .json()
237                .await
238                .map_err(|source| ContextError::GcpAuthHttp {
239                    endpoint: "iamcredentials",
240                    source,
241                })?;
242
243        if body.access_token.trim().is_empty() {
244            return Err(ContextError::GcpAuthInvalidResponse {
245                detail: "IAMCredentials response omitted accessToken".to_string(),
246            });
247        }
248        let expires_at = parse_google_expire_time(&body.expire_time).ok_or_else(|| {
249            ContextError::GcpAuthInvalidResponse {
250                detail: format!(
251                    "IAMCredentials response had invalid expireTime {:?}",
252                    body.expire_time
253                ),
254            }
255        })?;
256
257        Ok(GcpAccessToken {
258            token: body.access_token,
259            expires_at,
260        })
261    }
262}
263
264#[derive(Deserialize)]
265struct MetadataTokenResponse {
266    access_token: String,
267    expires_in: u64,
268}
269
270#[derive(Serialize)]
271struct GenerateAccessTokenRequest {
272    scope: Vec<String>,
273    lifetime: String,
274}
275
276#[derive(Deserialize)]
277struct GenerateAccessTokenResponse {
278    #[serde(rename = "accessToken")]
279    access_token: String,
280    #[serde(rename = "expireTime")]
281    expire_time: String,
282}
283
284async fn response_body_for_error(response: reqwest::Response) -> String {
285    match response.text().await {
286        Ok(body) => truncate_for_error(body),
287        Err(error) => format!("failed to read error body: {error}"),
288    }
289}
290
291fn truncate_for_error(mut body: String) -> String {
292    const MAX_ERROR_BODY_BYTES: usize = 512;
293    if body.len() <= MAX_ERROR_BODY_BYTES {
294        return body;
295    }
296    let mut end = MAX_ERROR_BODY_BYTES;
297    while !body.is_char_boundary(end) {
298        end -= 1;
299    }
300    body.truncate(end);
301    body.push_str("...");
302    body
303}
304
305fn parse_google_expire_time(expire_time: &str) -> Option<SystemTime> {
306    expire_time
307        .parse::<jiff::Timestamp>()
308        .ok()
309        .map(SystemTime::from)
310}
311
312fn token_expires_after_skew(expires_at: Option<SystemTime>, now: SystemTime) -> bool {
313    let Some(expires_at) = expires_at else {
314        return true;
315    };
316    let Some(refresh_at) = now.checked_add(Duration::from_secs(GCP_TOKEN_CACHE_SKEW_SECS)) else {
317        return false;
318    };
319    expires_at > refresh_at
320}
321
322/// Guard returned by [`OperatorContext::try_lock_database`].
323///
324/// Holding this guard prevents other reconcile loops (within the same process)
325/// from starting work on the same database target. The lock is released when
326/// the guard is dropped.
327pub struct DatabaseLockGuard {
328    key: String,
329    locks: Arc<Mutex<HashMap<String, ()>>>,
330}
331
332impl Drop for DatabaseLockGuard {
333    fn drop(&mut self) {
334        // Best-effort removal — `try_lock` avoids blocking the drop.
335        if let Ok(mut map) = self.locks.try_lock() {
336            map.remove(&self.key);
337            tracing::debug!(database = %self.key, "released in-memory database lock");
338        } else {
339            // Spawn a task to clean up if the mutex is currently held.
340            // Use Handle::try_current() so we don't panic when dropped
341            // outside an active Tokio runtime (e.g. during shutdown).
342            let key = self.key.clone();
343            let locks = Arc::clone(&self.locks);
344            if let Ok(handle) = tokio::runtime::Handle::try_current() {
345                handle.spawn(async move {
346                    locks.lock().await.remove(&key);
347                    tracing::debug!(database = %key, "released in-memory database lock (deferred)");
348                });
349                tracing::debug!(
350                    database = %self.key,
351                    "deferred in-memory database lock release to background task"
352                );
353            } else {
354                // No runtime available — fall back to synchronous cleanup
355                // via blocking_lock so the entry is still removed.
356                let mut map = self.locks.blocking_lock();
357                map.remove(&key);
358                tracing::debug!(
359                    database = %key,
360                    "released in-memory database lock (fallback sync)"
361                );
362            }
363        }
364    }
365}
366
367/// Shared state for the operator, passed to every reconciliation.
368#[derive(Clone)]
369pub struct OperatorContext {
370    /// Kubernetes client for API calls.
371    pub kube_client: kube::Client,
372
373    /// Kubernetes Event recorder for transition-based policy Events.
374    pub event_recorder: Recorder,
375
376    /// Cached database connection pools keyed by `"namespace/secret-name/secret-key"`.
377    pool_cache: Arc<RwLock<HashMap<String, CachedPool>>>,
378    /// In-process per-database reconciliation locks.
379    ///
380    /// Prevents concurrent reconcile loops from operating on the same database
381    /// within a single operator replica. Cross-replica safety is provided by
382    /// PostgreSQL advisory locks (see [`crate::advisory`]).
383    database_locks: Arc<Mutex<HashMap<String, ()>>>,
384
385    /// Shared health/metrics state.
386    pub observability: OperatorObservability,
387
388    /// Fetches short-lived provider-backed database passwords.
389    gcp_token_provider: Arc<dyn GcpAccessTokenProvider>,
390}
391
392impl OperatorContext {
393    /// Create a new operator context with an empty pool cache.
394    pub fn new(
395        kube_client: kube::Client,
396        observability: OperatorObservability,
397        event_recorder: Recorder,
398    ) -> Self {
399        Self {
400            kube_client,
401            event_recorder,
402            pool_cache: Arc::new(RwLock::new(HashMap::new())),
403            observability,
404            database_locks: Arc::new(Mutex::new(HashMap::new())),
405            gcp_token_provider: Arc::new(MetadataGcpAccessTokenProvider::default()),
406        }
407    }
408
409    /// Try to acquire the in-process lock for the given database identity.
410    ///
411    /// Returns `Some(guard)` if no other reconcile is in progress for this
412    /// database, `None` if one is already running. The lock is released when
413    /// the guard is dropped.
414    pub async fn try_lock_database(&self, database_identity: &str) -> Option<DatabaseLockGuard> {
415        let mut locks = self.database_locks.lock().await;
416        if locks.contains_key(database_identity) {
417            tracing::info!(
418                database = %database_identity,
419                "in-memory database lock contention — another reconcile is in progress"
420            );
421            return None;
422        }
423        locks.insert(database_identity.to_string(), ());
424        tracing::debug!(database = %database_identity, "acquired in-memory database lock");
425        Some(DatabaseLockGuard {
426            key: database_identity.to_string(),
427            locks: Arc::clone(&self.database_locks),
428        })
429    }
430
431    /// Resolve a param from either its literal value or a Secret reference.
432    ///
433    /// Returns `Ok(Some(value))` if one is set, `Ok(None)` if neither is set.
434    async fn resolve_param(
435        &self,
436        namespace: &str,
437        literal: &Option<String>,
438        secret: &Option<SecretKeySelector>,
439    ) -> Result<Option<String>, ContextError> {
440        if let Some(val) = literal {
441            return Ok(Some(val.clone()));
442        }
443        if let Some(sel) = secret {
444            return Ok(Some(
445                self.fetch_secret_value(namespace, &sel.name, &sel.key)
446                    .await?,
447            ));
448        }
449        Ok(None)
450    }
451
452    /// Resolve a [`ConnectionSpec`] into a PostgreSQL connection URL string.
453    ///
454    /// - **URL mode** (`secret_ref` is Some): reads the Secret key as a connection URL.
455    /// - **Params mode** (`params` is Some): resolves each field and constructs a URL.
456    pub async fn resolve_connection_url(
457        &self,
458        namespace: &str,
459        connection: &ConnectionSpec,
460    ) -> Result<String, ContextError> {
461        Ok(self
462            .resolve_connection_url_with_metadata(namespace, connection)
463            .await?
464            .database_url)
465    }
466
467    async fn resolve_connection_url_with_metadata(
468        &self,
469        namespace: &str,
470        connection: &ConnectionSpec,
471    ) -> Result<ResolvedConnectionUrl, ContextError> {
472        if let Some(ref secret_ref) = connection.secret_ref {
473            // URL mode — read the full connection URL from the Secret.
474            let database_url = self
475                .fetch_secret_value(
476                    namespace,
477                    &secret_ref.name,
478                    connection.effective_secret_key(),
479                )
480                .await?;
481            Ok(ResolvedConnectionUrl {
482                database_url,
483                token_expires_at: None,
484                set_role: None,
485            })
486        } else if let Some(ref params) = connection.params {
487            // Params mode — resolve each field and build the URL.
488            let host = self
489                .resolve_param(namespace, &params.host, &params.host_secret)
490                .await?
491                .ok_or_else(|| ContextError::EmptyResolvedValue {
492                    field: "host".to_string(),
493                })?;
494            if host.trim().is_empty() {
495                return Err(ContextError::EmptyResolvedValue {
496                    field: "host".to_string(),
497                });
498            }
499
500            let port_str = params.port.map(|p| p.to_string());
501            let port = self
502                .resolve_param(namespace, &port_str, &params.port_secret)
503                .await?
504                .unwrap_or_else(|| "5432".to_string());
505            if port.trim().is_empty() {
506                return Err(ContextError::EmptyResolvedValue {
507                    field: "port".to_string(),
508                });
509            }
510
511            let dbname = self
512                .resolve_param(namespace, &params.dbname, &params.dbname_secret)
513                .await?
514                .ok_or_else(|| ContextError::EmptyResolvedValue {
515                    field: "dbname".to_string(),
516                })?;
517            if dbname.trim().is_empty() {
518                return Err(ContextError::EmptyResolvedValue {
519                    field: "dbname".to_string(),
520                });
521            }
522
523            let username = self
524                .resolve_param(namespace, &params.username, &params.username_secret)
525                .await?
526                .ok_or_else(|| ContextError::EmptyResolvedValue {
527                    field: "username".to_string(),
528                })?;
529            if username.trim().is_empty() {
530                return Err(ContextError::EmptyResolvedValue {
531                    field: "username".to_string(),
532                });
533            }
534
535            let (password, token_expires_at) = if let Some(auth) = &params.auth {
536                let token = self.gcp_token_provider.fetch_token(auth).await?;
537                (token.token, Some(token.expires_at))
538            } else {
539                let password = self
540                    .resolve_param(namespace, &params.password, &params.password_secret)
541                    .await?
542                    .ok_or_else(|| ContextError::EmptyResolvedValue {
543                        field: "password".to_string(),
544                    })?;
545                (password, None)
546            };
547            if password.trim().is_empty() {
548                return Err(ContextError::EmptyResolvedValue {
549                    field: "password".to_string(),
550                });
551            }
552
553            use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
554            let encoded_username = utf8_percent_encode(&username, NON_ALPHANUMERIC).to_string();
555            let encoded_password = utf8_percent_encode(&password, NON_ALPHANUMERIC).to_string();
556
557            let mut url = format!(
558                "postgresql://{encoded_username}:{encoded_password}@{host}:{port}/{dbname}"
559            );
560
561            let ssl_mode = self
562                .resolve_param(namespace, &params.ssl_mode, &params.ssl_mode_secret)
563                .await?
564                .or_else(|| params.auth.as_ref().map(|_| "require".to_string()));
565            if let Some(ssl_mode) = ssl_mode {
566                // Validate sslMode at runtime — CRD validation only catches
567                // literal values; a secret ref could resolve to anything.
568                if !crate::crd::VALID_SSL_MODES.contains(&ssl_mode.as_str()) {
569                    return Err(ContextError::InvalidResolvedSslMode { value: ssl_mode });
570                }
571                url.push_str("?sslmode=");
572                url.push_str(&ssl_mode);
573            }
574
575            Ok(ResolvedConnectionUrl {
576                database_url: url,
577                token_expires_at,
578                set_role: params.set_role.clone(),
579            })
580        } else {
581            Err(ContextError::SecretMissing {
582                name: "connection".to_string(),
583                key: "neither secretRef nor params is set".to_string(),
584            })
585        }
586    }
587
588    /// Get or create a PgPool for the given connection spec.
589    ///
590    /// Resolves the connection URL from the referenced Secret(s),
591    /// and caches the resulting pool for reuse.
592    pub async fn get_or_create_pool(
593        &self,
594        namespace: &str,
595        connection: &ConnectionSpec,
596    ) -> Result<PgPool, ContextError> {
597        let cache_key = connection.cache_key(namespace);
598
599        // For URL mode, we can do resource-version-based cache invalidation.
600        // For params mode, compute a fingerprint from all referenced secrets'
601        // resourceVersions so that secret rotations invalidate the cache.
602        let (resource_version, secret_fingerprint) =
603            if let Some(ref secret_ref) = connection.secret_ref {
604                let secrets_api: kube::Api<k8s_openapi::api::core::v1::Secret> =
605                    kube::Api::namespaced(self.kube_client.clone(), namespace);
606                let secret = secrets_api.get(&secret_ref.name).await.map_err(|err| {
607                    ContextError::SecretFetch {
608                        name: secret_ref.name.clone(),
609                        namespace: namespace.to_string(),
610                        source: err,
611                    }
612                })?;
613                (secret.metadata.resource_version, None)
614            } else if connection.params.is_some() {
615                // Params mode — collect all referenced secret names and fetch their
616                // resourceVersions to build a fingerprint.
617                let mut secret_names = std::collections::BTreeSet::new();
618                connection.collect_secret_names(&mut secret_names);
619
620                if secret_names.is_empty() {
621                    // All values are literals — no secrets to watch.
622                    (None, Some(String::new()))
623                } else {
624                    let secrets_api: kube::Api<k8s_openapi::api::core::v1::Secret> =
625                        kube::Api::namespaced(self.kube_client.clone(), namespace);
626                    let mut fingerprint_parts = Vec::new();
627                    for name in &secret_names {
628                        let secret = secrets_api.get(name).await.map_err(|err| {
629                            ContextError::SecretFetch {
630                                name: name.clone(),
631                                namespace: namespace.to_string(),
632                                source: err,
633                            }
634                        })?;
635                        let rv = secret
636                            .metadata
637                            .resource_version
638                            .unwrap_or_else(|| "unknown".to_string());
639                        fingerprint_parts.push(format!("{name}={rv}"));
640                    }
641                    (None, Some(fingerprint_parts.join(",")))
642                }
643            } else {
644                (None, None)
645            };
646
647        // Check cache.
648        {
649            let cache = self.pool_cache.read().await;
650            if let Some(cached) = cache.get(&cache_key) {
651                // URL mode: reuse if the Secret's resource_version matches.
652                // Params mode: reuse if the secret fingerprint matches.
653                let version_matches = match (&resource_version, &cached.resource_version) {
654                    (Some(current), Some(cached_rv)) => current == cached_rv,
655                    _ => true,
656                };
657                let fingerprint_matches = match (&secret_fingerprint, &cached.secret_fingerprint) {
658                    (Some(current), Some(cached_fp)) => current == cached_fp,
659                    (None, None) => true,
660                    _ => false,
661                };
662                let token_fresh =
663                    token_expires_after_skew(cached.token_expires_at, SystemTime::now());
664                if version_matches && fingerprint_matches && token_fresh {
665                    return Ok(cached.pool.clone());
666                }
667            }
668        }
669
670        let resolved = self
671            .resolve_connection_url_with_metadata(namespace, connection)
672            .await?;
673
674        // Create pool with explicit sizing. Reconciliation holds one dedicated
675        // connection for PostgreSQL advisory locking and needs additional pool
676        // capacity for inspection/apply queries.
677        let set_role = resolved.set_role.clone();
678        let pool = PgPoolOptions::new()
679            .max_connections(POOL_MAX_CONNECTIONS)
680            .acquire_timeout(Duration::from_secs(POOL_ACQUIRE_TIMEOUT_SECS))
681            .after_connect(move |conn, _meta| {
682                let set_role = set_role.clone();
683                Box::pin(async move {
684                    if let Some(role) = set_role {
685                        let stmt = build_set_role_stmt(&role);
686                        sqlx::Executor::execute(&mut *conn, stmt.as_str())
687                            .await
688                            .map_err(|err| wrap_set_role_failure(&role, err))?;
689                    }
690                    Ok(())
691                })
692            })
693            .connect(&resolved.database_url)
694            .await
695            .map_err(|err| classify_pool_connect_error(resolved.set_role.as_deref(), err))?;
696
697        // Cache it (write lock).
698        {
699            let mut cache = self.pool_cache.write().await;
700            cache.insert(
701                cache_key,
702                CachedPool {
703                    resource_version,
704                    secret_fingerprint,
705                    token_expires_at: resolved.token_expires_at,
706                    pool: pool.clone(),
707                },
708            );
709        }
710
711        Ok(pool)
712    }
713
714    /// Fetch a single string value from a Kubernetes Secret.
715    ///
716    /// Used to resolve role passwords from Secret references at reconcile time.
717    pub async fn fetch_secret_value(
718        &self,
719        namespace: &str,
720        secret_name: &str,
721        secret_key: &str,
722    ) -> Result<String, ContextError> {
723        let secrets_api: kube::Api<k8s_openapi::api::core::v1::Secret> =
724            kube::Api::namespaced(self.kube_client.clone(), namespace);
725
726        let secret =
727            secrets_api
728                .get(secret_name)
729                .await
730                .map_err(|err| ContextError::SecretFetch {
731                    name: secret_name.to_string(),
732                    namespace: namespace.to_string(),
733                    source: err,
734                })?;
735
736        let data = secret.data.ok_or_else(|| ContextError::SecretMissing {
737            name: secret_name.to_string(),
738            key: secret_key.to_string(),
739        })?;
740
741        let value_bytes = data
742            .get(secret_key)
743            .ok_or_else(|| ContextError::SecretMissing {
744                name: secret_name.to_string(),
745                key: secret_key.to_string(),
746            })?;
747
748        String::from_utf8(value_bytes.0.clone()).map_err(|_| ContextError::SecretMissing {
749            name: secret_name.to_string(),
750            key: secret_key.to_string(),
751        })
752    }
753
754    /// Remove a cached pool (e.g. when secret changes or CR is deleted).
755    pub async fn evict_pool(&self, namespace: &str, connection: &ConnectionSpec) {
756        let cache_key = connection.cache_key(namespace);
757        let mut cache = self.pool_cache.write().await;
758        cache.remove(&cache_key);
759    }
760}
761
762/// Errors from operator context operations.
763#[derive(Debug, thiserror::Error)]
764pub enum ContextError {
765    #[error("failed to fetch Secret {namespace}/{name}: {source}")]
766    SecretFetch {
767        name: String,
768        namespace: String,
769        source: kube::Error,
770    },
771
772    #[error("Secret \"{name}\" does not contain key \"{key}\"")]
773    SecretMissing { name: String, key: String },
774
775    #[error("failed to connect to database: {source}")]
776    DatabaseConnect { source: sqlx::Error },
777
778    #[error("failed to apply SET ROLE \"{role}\" on pooled connection: {source}")]
779    SetRoleFailed { role: String, source: sqlx::Error },
780
781    #[error("connection param \"{field}\" resolved to an empty or whitespace-only value")]
782    EmptyResolvedValue { field: String },
783
784    #[error(
785        "connection param sslMode resolved to invalid value \"{value}\" (expected one of: disable, allow, prefer, require, verify-ca, verify-full)"
786    )]
787    InvalidResolvedSslMode { value: String },
788
789    #[error("failed to fetch GCP auth token from {endpoint}: {source}")]
790    GcpAuthHttp {
791        endpoint: &'static str,
792        source: reqwest::Error,
793    },
794
795    #[error("GCP auth token endpoint {endpoint} returned HTTP {status}: {body}")]
796    GcpAuthRejected {
797        endpoint: String,
798        status: u16,
799        body: String,
800    },
801
802    #[error("GCP auth token response was invalid: {detail}")]
803    GcpAuthInvalidResponse { detail: String },
804}
805
806impl ContextError {
807    /// Returns true when a Secret fetch failed due to a non-transient client-side API error.
808    pub fn is_secret_fetch_non_transient(&self) -> bool {
809        matches!(
810            self,
811            ContextError::SecretFetch {
812                source: kube::Error::Api(response),
813                ..
814            } if (400..500).contains(&response.code) && response.code != 429
815        )
816    }
817
818    pub fn is_gcp_auth_non_transient(&self) -> bool {
819        matches!(
820            self,
821            ContextError::GcpAuthRejected { status, .. }
822                if (400..500).contains(status) && *status != 429
823        ) || matches!(self, ContextError::GcpAuthInvalidResponse { .. })
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn build_set_role_stmt_quotes_identifier() {
833        assert_eq!(
834            build_set_role_stmt("cloudsqlsuperuser"),
835            "SET ROLE \"cloudsqlsuperuser\"",
836        );
837    }
838
839    #[test]
840    fn classify_pool_connect_error_surfaces_set_role_failure_with_role() {
841        let raw = wrap_set_role_failure(
842            "cloudsqlsuperuser",
843            sqlx::Error::Protocol("permission denied".to_string()),
844        );
845        let classified = classify_pool_connect_error(Some("cloudsqlsuperuser"), raw);
846        assert!(matches!(
847            classified,
848            ContextError::SetRoleFailed { ref role, .. } if role == "cloudsqlsuperuser"
849        ));
850    }
851
852    #[test]
853    fn classify_pool_connect_error_passes_through_unrelated_errors() {
854        let err = sqlx::Error::PoolTimedOut;
855        let classified = classify_pool_connect_error(Some("any_role"), err);
856        assert!(matches!(classified, ContextError::DatabaseConnect { .. }));
857    }
858
859    #[test]
860    fn classify_pool_connect_error_without_set_role_is_database_connect() {
861        // Even a Protocol error with our marker shouldn't be classified as
862        // SetRoleFailed when no role was configured for this pool.
863        let raw = wrap_set_role_failure("ghost", sqlx::Error::Protocol("oops".to_string()));
864        let classified = classify_pool_connect_error(None, raw);
865        assert!(matches!(classified, ContextError::DatabaseConnect { .. }));
866    }
867
868    #[test]
869    fn build_set_role_stmt_doubles_embedded_quote() {
870        // CRD validation rejects identifiers containing `"`. This test pins
871        // the defensive quoting in the connection-pool path anyway, so a
872        // future relaxation of the validator can't silently allow injection.
873        assert_eq!(build_set_role_stmt("a\"b"), "SET ROLE \"a\"\"b\"",);
874    }
875
876    #[test]
877    fn pool_cache_key_format() {
878        // Verify the cache key format is "namespace/secret-name/secret-key"
879        let key = format!("{}/{}/{}", "prod", "pg-credentials", "DATABASE_URL");
880        assert_eq!(key, "prod/pg-credentials/DATABASE_URL");
881    }
882
883    #[test]
884    fn secret_fetch_not_found_is_non_transient() {
885        let error = ContextError::SecretFetch {
886            name: "db-credentials".into(),
887            namespace: "default".into(),
888            source: kube::Error::Api(
889                kube::core::Status::failure("secrets \"db-credentials\" not found", "NotFound")
890                    .with_code(404)
891                    .boxed(),
892            ),
893        };
894
895        assert!(error.is_secret_fetch_non_transient());
896    }
897
898    #[test]
899    fn secret_fetch_forbidden_is_non_transient() {
900        let error = ContextError::SecretFetch {
901            name: "db-credentials".into(),
902            namespace: "default".into(),
903            source: kube::Error::Api(
904                kube::core::Status::failure("forbidden", "Forbidden")
905                    .with_code(403)
906                    .boxed(),
907            ),
908        };
909
910        assert!(error.is_secret_fetch_non_transient());
911    }
912
913    #[test]
914    fn secret_fetch_server_error_remains_transient() {
915        let error = ContextError::SecretFetch {
916            name: "db-credentials".into(),
917            namespace: "default".into(),
918            source: kube::Error::Api(
919                kube::core::Status::failure("internal error", "InternalError")
920                    .with_code(500)
921                    .boxed(),
922            ),
923        };
924
925        assert!(!error.is_secret_fetch_non_transient());
926    }
927
928    #[test]
929    fn gcp_auth_client_error_is_non_transient() {
930        let error = ContextError::GcpAuthRejected {
931            endpoint: "metadata".into(),
932            status: 403,
933            body: "forbidden".into(),
934        };
935
936        assert!(error.is_gcp_auth_non_transient());
937    }
938
939    #[test]
940    fn gcp_auth_rate_limit_remains_transient() {
941        let error = ContextError::GcpAuthRejected {
942            endpoint: "metadata".into(),
943            status: 429,
944            body: "rate limited".into(),
945        };
946
947        assert!(!error.is_gcp_auth_non_transient());
948    }
949
950    #[test]
951    fn token_expiry_uses_five_minute_refresh_skew() {
952        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
953        assert!(token_expires_after_skew(
954            Some(now + Duration::from_secs(GCP_TOKEN_CACHE_SKEW_SECS + 1)),
955            now
956        ));
957        assert!(!token_expires_after_skew(
958            Some(now + Duration::from_secs(GCP_TOKEN_CACHE_SKEW_SECS)),
959            now
960        ));
961    }
962
963    #[test]
964    fn parse_google_expire_time_accepts_rfc3339() {
965        let parsed =
966            parse_google_expire_time("2026-05-14T02:30:00Z").expect("expireTime should parse");
967        assert_eq!(
968            parsed
969                .duration_since(SystemTime::UNIX_EPOCH)
970                .unwrap()
971                .as_secs(),
972            1_778_725_800
973        );
974    }
975
976    #[test]
977    fn truncate_for_error_keeps_utf8_boundary() {
978        let body = "é".repeat(300);
979        let truncated = truncate_for_error(body);
980
981        assert!(truncated.ends_with("..."));
982        assert!(truncated.is_char_boundary(truncated.len() - 3));
983    }
984
985    #[tokio::test]
986    async fn try_lock_database_acquires_when_free() {
987        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
988        let ctx = OperatorContextLockHelper {
989            database_locks: locks,
990        };
991        let guard = ctx.try_lock("db-a").await;
992        assert!(guard.is_some(), "should acquire lock on free database");
993    }
994
995    #[tokio::test]
996    async fn try_lock_database_contention_returns_none() {
997        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
998        let ctx = OperatorContextLockHelper {
999            database_locks: locks,
1000        };
1001
1002        let _guard1 = ctx
1003            .try_lock("db-a")
1004            .await
1005            .expect("first lock should succeed");
1006        let guard2 = ctx.try_lock("db-a").await;
1007        assert!(guard2.is_none(), "second lock on same database should fail");
1008    }
1009
1010    #[tokio::test]
1011    async fn try_lock_database_different_databases_independent() {
1012        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
1013        let ctx = OperatorContextLockHelper {
1014            database_locks: locks,
1015        };
1016
1017        let guard_a = ctx.try_lock("db-a").await;
1018        let guard_b = ctx.try_lock("db-b").await;
1019        assert!(guard_a.is_some(), "lock on db-a should succeed");
1020        assert!(
1021            guard_b.is_some(),
1022            "lock on db-b should succeed (different database)"
1023        );
1024    }
1025
1026    #[tokio::test]
1027    async fn try_lock_database_released_after_drop() {
1028        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
1029        let ctx = OperatorContextLockHelper {
1030            database_locks: Arc::clone(&locks),
1031        };
1032
1033        {
1034            let _guard = ctx.try_lock("db-a").await.expect("should acquire");
1035            // guard is dropped here
1036        }
1037
1038        // After drop, should be able to acquire again.
1039        let guard2 = ctx.try_lock("db-a").await;
1040        assert!(
1041            guard2.is_some(),
1042            "should re-acquire after previous guard dropped"
1043        );
1044    }
1045
1046    #[tokio::test]
1047    async fn try_lock_database_concurrent_contention() {
1048        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
1049
1050        // Simulate two concurrent reconciles for the same database.
1051        let locks1 = Arc::clone(&locks);
1052        let locks2 = Arc::clone(&locks);
1053
1054        let handle1 = tokio::spawn(async move {
1055            let ctx = OperatorContextLockHelper {
1056                database_locks: locks1,
1057            };
1058            let guard = ctx.try_lock("shared-db").await;
1059            if guard.is_some() {
1060                // Hold the lock briefly.
1061                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1062            }
1063            guard.is_some()
1064        });
1065
1066        // Small delay so handle1 is likely first.
1067        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1068
1069        let handle2 = tokio::spawn(async move {
1070            let ctx = OperatorContextLockHelper {
1071                database_locks: locks2,
1072            };
1073            let guard = ctx.try_lock("shared-db").await;
1074            guard.is_some()
1075        });
1076
1077        let (r1, r2) = tokio::join!(handle1, handle2);
1078        let acquired1 = r1.unwrap();
1079        let acquired2 = r2.unwrap();
1080
1081        // Exactly one should succeed.
1082        assert!(
1083            acquired1 ^ acquired2,
1084            "exactly one of two concurrent locks should succeed: got ({acquired1}, {acquired2})"
1085        );
1086    }
1087
1088    /// Helper to test locking without a real kube client.
1089    struct OperatorContextLockHelper {
1090        database_locks: Arc<Mutex<HashMap<String, ()>>>,
1091    }
1092
1093    impl OperatorContextLockHelper {
1094        async fn try_lock(&self, database_identity: &str) -> Option<DatabaseLockGuard> {
1095            let mut locks = self.database_locks.lock().await;
1096            if locks.contains_key(database_identity) {
1097                return None;
1098            }
1099            locks.insert(database_identity.to_string(), ());
1100            Some(DatabaseLockGuard {
1101                key: database_identity.to_string(),
1102                locks: Arc::clone(&self.database_locks),
1103            })
1104        }
1105    }
1106
1107    #[tokio::test]
1108    async fn try_lock_database_high_concurrency_same_db() {
1109        // Spawn many tasks all racing to lock the same database.
1110        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
1111        let concurrency = 50;
1112        let acquired_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1113        let barrier = Arc::new(tokio::sync::Barrier::new(concurrency));
1114
1115        let mut handles = Vec::with_capacity(concurrency);
1116        for _ in 0..concurrency {
1117            let locks_clone = Arc::clone(&locks);
1118            let count = Arc::clone(&acquired_count);
1119            let bar = Arc::clone(&barrier);
1120            handles.push(tokio::spawn(async move {
1121                // Synchronize start so all tasks race at the same instant.
1122                bar.wait().await;
1123                let ctx = OperatorContextLockHelper {
1124                    database_locks: locks_clone,
1125                };
1126                let guard = ctx.try_lock("contested-db").await;
1127                if guard.is_some() {
1128                    count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1129                    // Hold lock briefly to let other tasks observe contention.
1130                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1131                }
1132            }));
1133        }
1134
1135        for h in handles {
1136            h.await.unwrap();
1137        }
1138
1139        // Exactly one task should have acquired the lock.
1140        let total = acquired_count.load(std::sync::atomic::Ordering::SeqCst);
1141        assert_eq!(
1142            total, 1,
1143            "exactly one of {concurrency} concurrent tasks should acquire the lock, got {total}"
1144        );
1145    }
1146
1147    #[tokio::test]
1148    async fn try_lock_database_high_concurrency_different_dbs() {
1149        // Many tasks each locking a different database — all should succeed.
1150        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
1151        let concurrency = 50;
1152        let acquired_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1153        let barrier = Arc::new(tokio::sync::Barrier::new(concurrency));
1154
1155        let mut handles = Vec::with_capacity(concurrency);
1156        for i in 0..concurrency {
1157            let locks_clone = Arc::clone(&locks);
1158            let count = Arc::clone(&acquired_count);
1159            let bar = Arc::clone(&barrier);
1160            handles.push(tokio::spawn(async move {
1161                bar.wait().await;
1162                let ctx = OperatorContextLockHelper {
1163                    database_locks: locks_clone,
1164                };
1165                let db_name = format!("db-{i}");
1166                let guard = ctx.try_lock(&db_name).await;
1167                if guard.is_some() {
1168                    count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1169                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1170                }
1171            }));
1172        }
1173
1174        for h in handles {
1175            h.await.unwrap();
1176        }
1177
1178        let total = acquired_count.load(std::sync::atomic::Ordering::SeqCst);
1179        assert_eq!(
1180            total, concurrency,
1181            "all {concurrency} tasks locking different dbs should succeed, got {total}"
1182        );
1183    }
1184
1185    #[tokio::test]
1186    async fn try_lock_database_acquire_release_cycle_under_contention() {
1187        // Repeatedly acquire and release the same database lock from many tasks.
1188        // Each task attempts the lock in a loop until it succeeds, simulating
1189        // the requeue-after-contention pattern used in the reconciler.
1190        let locks: Arc<Mutex<HashMap<String, ()>>> = Arc::new(Mutex::new(HashMap::new()));
1191        let concurrency = 20;
1192        let success_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1193        let barrier = Arc::new(tokio::sync::Barrier::new(concurrency));
1194
1195        let mut handles = Vec::with_capacity(concurrency);
1196        for _ in 0..concurrency {
1197            let locks_clone = Arc::clone(&locks);
1198            let count = Arc::clone(&success_count);
1199            let bar = Arc::clone(&barrier);
1200            handles.push(tokio::spawn(async move {
1201                bar.wait().await;
1202                // Retry up to 100 times with a small sleep between attempts,
1203                // simulating the jittered requeue pattern.
1204                for _ in 0..100 {
1205                    let ctx = OperatorContextLockHelper {
1206                        database_locks: Arc::clone(&locks_clone),
1207                    };
1208                    if let Some(_guard) = ctx.try_lock("shared-db").await {
1209                        count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1210                        // Brief simulated work, then guard drops (releasing lock).
1211                        tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1212                        return;
1213                    }
1214                    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1215                }
1216                // Should not reach here in practice — fail the test if we do.
1217                panic!("task failed to acquire lock after 100 retries");
1218            }));
1219        }
1220
1221        for h in handles {
1222            h.await.unwrap();
1223        }
1224
1225        let total = success_count.load(std::sync::atomic::Ordering::SeqCst);
1226        assert_eq!(
1227            total, concurrency,
1228            "all {concurrency} tasks should eventually acquire the lock"
1229        );
1230    }
1231}