1use 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
17const POOL_MAX_CONNECTIONS: u32 = 5;
22
23const 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 secret_fingerprint: Option<String>,
41 token_expires_at: Option<SystemTime>,
43 pool: PgPool,
44}
45
46struct ResolvedConnectionUrl {
47 database_url: String,
48 token_expires_at: Option<SystemTime>,
49 set_role: Option<String>,
53}
54
55pub fn build_set_role_stmt(role: &str) -> String {
62 format!("SET ROLE \"{}\"", role.replace('"', "\"\""))
63}
64
65const SET_ROLE_FAILURE_MARKER: &str = "pgroles:set-role-failed:";
68
69fn wrap_set_role_failure(role: &str, source: sqlx::Error) -> sqlx::Error {
74 sqlx::Error::Protocol(format!("{SET_ROLE_FAILURE_MARKER}{role}: {source}"))
75}
76
77fn 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
322pub struct DatabaseLockGuard {
328 key: String,
329 locks: Arc<Mutex<HashMap<String, ()>>>,
330}
331
332impl Drop for DatabaseLockGuard {
333 fn drop(&mut self) {
334 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 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 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#[derive(Clone)]
369pub struct OperatorContext {
370 pub kube_client: kube::Client,
372
373 pub event_recorder: Recorder,
375
376 pool_cache: Arc<RwLock<HashMap<String, CachedPool>>>,
378 database_locks: Arc<Mutex<HashMap<String, ()>>>,
384
385 pub observability: OperatorObservability,
387
388 gcp_token_provider: Arc<dyn GcpAccessTokenProvider>,
390}
391
392impl OperatorContext {
393 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 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 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 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 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 let host = self
489 .resolve_param(namespace, ¶ms.host, ¶ms.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, ¶ms.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, ¶ms.dbname, ¶ms.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, ¶ms.username, ¶ms.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) = ¶ms.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, ¶ms.password, ¶ms.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, ¶ms.ssl_mode, ¶ms.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 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 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 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 let mut secret_names = std::collections::BTreeSet::new();
618 connection.collect_secret_names(&mut secret_names);
619
620 if secret_names.is_empty() {
621 (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 {
649 let cache = self.pool_cache.read().await;
650 if let Some(cached) = cache.get(&cache_key) {
651 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 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 {
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 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 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#[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 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 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 assert_eq!(build_set_role_stmt("a\"b"), "SET ROLE \"a\"\"b\"",);
874 }
875
876 #[test]
877 fn pool_cache_key_format() {
878 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 }
1037
1038 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 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 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1062 }
1063 guard.is_some()
1064 });
1065
1066 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 assert!(
1083 acquired1 ^ acquired2,
1084 "exactly one of two concurrent locks should succeed: got ({acquired1}, {acquired2})"
1085 );
1086 }
1087
1088 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 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 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 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 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 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 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 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 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 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}