1use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
5
6use object_store::{
7 ClientOptions, CredentialProvider, ObjectStore as OSObjectStore, Result as ObjectStoreResult,
8 client::{HttpClient, HttpConnector, HttpRequestBody, ReqwestConnector},
9};
10use opendal::{Operator, services::Gcs};
11use reqsign_core::{Context as ReqsignContext, HttpSend, OsEnv, ProvideCredential};
12use reqsign_file_read_tokio::TokioFileRead;
13use reqsign_google::{Credential as ReqsignCredential, FileCredentialProvider};
14use tokio::sync::RwLock;
15
16use object_store::{
17 RetryConfig, StaticCredentialProvider,
18 gcp::{GcpCredential, GoogleCloudStorageBuilder, GoogleConfigKey},
19};
20use url::Url;
21
22use crate::object_store::opendal_store::OpendalStore;
23use crate::object_store::{
24 DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
25 ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
26 dynamic_credentials::build_dynamic_credential_provider,
27 throttle::{AimdThrottleConfig, AimdThrottleState, AimdThrottledStore, cloud_http_connector},
28};
29use lance_core::error::{Error, Result};
30
31#[derive(Default, Debug)]
32pub struct GcsStoreProvider;
33
34#[derive(Debug)]
35struct ObjectStoreHttpSend {
36 client: HttpClient,
37}
38
39impl HttpSend for ObjectStoreHttpSend {
40 async fn http_send(
41 &self,
42 request: http::Request<bytes::Bytes>,
43 ) -> reqsign_core::Result<http::Response<bytes::Bytes>> {
44 let (parts, body) = request.into_parts();
45 let request = http::Request::from_parts(parts, HttpRequestBody::from(body));
46 let response = self.client.execute(request).await.map_err(|source| {
47 reqsign_core::Error::unexpected("failed to send Google workload identity HTTP request")
48 .with_source(source)
49 })?;
50 let (parts, body) = response.into_parts();
51 let body = body.bytes().await.map_err(|source| {
52 reqsign_core::Error::unexpected("failed to read Google workload identity HTTP response")
53 .with_source(source)
54 })?;
55 Ok(http::Response::from_parts(parts, body))
56 }
57}
58
59#[derive(Debug)]
60struct WorkloadIdentityCredentialProvider {
61 provider: FileCredentialProvider,
62 context: ReqsignContext,
63 cached_credential: RwLock<Option<ReqsignCredential>>,
64}
65
66impl WorkloadIdentityCredentialProvider {
67 fn new(application_credentials_path: String, http_client: HttpClient) -> Self {
68 let context = ReqsignContext::new()
69 .with_file_read(TokioFileRead)
70 .with_http_send(ObjectStoreHttpSend {
71 client: http_client,
72 })
73 .with_env(OsEnv);
74 Self {
75 provider: FileCredentialProvider::new(application_credentials_path),
76 context,
77 cached_credential: RwLock::new(None),
78 }
79 }
80}
81
82fn usable_gcp_credential(credential: &ReqsignCredential) -> Option<GcpCredential> {
83 credential
84 .token
85 .as_ref()
86 .filter(|_| credential.has_valid_token())
87 .map(|token| GcpCredential {
88 bearer: token.access_token.clone(),
89 })
90}
91
92fn workload_identity_error(
93 source: impl std::error::Error + Send + Sync + 'static,
94) -> object_store::Error {
95 object_store::Error::Generic {
96 store: "GCS workload identity credentials",
97 source: Box::new(source),
98 }
99}
100
101#[async_trait::async_trait]
102impl CredentialProvider for WorkloadIdentityCredentialProvider {
103 type Credential = GcpCredential;
104
105 async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
106 if let Some(credential) = self
107 .cached_credential
108 .read()
109 .await
110 .as_ref()
111 .and_then(usable_gcp_credential)
112 {
113 return Ok(Arc::new(credential));
114 }
115
116 let mut cached_credential = self.cached_credential.write().await;
117 if let Some(credential) = cached_credential.as_ref().and_then(usable_gcp_credential) {
118 return Ok(Arc::new(credential));
119 }
120
121 let credential = self
122 .provider
123 .provide_credential(&self.context)
124 .await
125 .map_err(workload_identity_error)?
126 .ok_or_else(|| {
127 workload_identity_error(std::io::Error::other(
128 "application credentials did not provide a Google access token",
129 ))
130 })?;
131 let gcp_credential = usable_gcp_credential(&credential).ok_or_else(|| {
132 workload_identity_error(std::io::Error::other(
133 "application credentials provided an expired or unusable Google access token",
134 ))
135 })?;
136 *cached_credential = Some(credential);
137 Ok(Arc::new(gcp_credential))
138 }
139}
140
141#[derive(serde::Deserialize)]
142struct ApplicationCredentialKind {
143 #[serde(rename = "type")]
144 credential_type: String,
145}
146
147struct GcsClientOptions {
148 object_requests: ClientOptions,
149 credential_requests: ClientOptions,
150}
151
152fn gcs_client_options(storage_options: &StorageOptions) -> Result<GcsClientOptions> {
153 let mut object_requests = storage_options.client_options()?;
154 let mut credential_requests = object_requests
157 .clone()
158 .with_default_headers(Default::default());
159 for (key, value) in storage_options.as_gcs_options() {
160 if let GoogleConfigKey::Client(key) = key {
161 object_requests = object_requests.with_config(key, value.clone());
162 credential_requests = credential_requests.with_config(key, value);
163 }
164 }
165 Ok(GcsClientOptions {
166 object_requests,
167 credential_requests,
168 })
169}
170
171fn workload_identity_credential_provider(
172 storage_options: &StorageOptions,
173 client_options: &ClientOptions,
174) -> Result<Option<Arc<dyn CredentialProvider<Credential = GcpCredential>>>> {
175 let gcs_options = storage_options.as_gcs_options();
176 if gcs_options.contains_key(&GoogleConfigKey::ServiceAccount)
177 || gcs_options.contains_key(&GoogleConfigKey::ServiceAccountKey)
178 {
179 return Ok(None);
180 }
181
182 let Some(application_credentials_path) =
183 gcs_options.get(&GoogleConfigKey::ApplicationCredentials)
184 else {
185 return Ok(None);
186 };
187 let Ok(contents) = std::fs::read(application_credentials_path) else {
188 return Ok(None);
189 };
190 let Ok(credential_kind) = serde_json::from_slice::<ApplicationCredentialKind>(&contents) else {
191 return Ok(None);
192 };
193 if credential_kind.credential_type != "external_account" {
194 return Ok(None);
195 }
196
197 let http_client = ReqwestConnector::default().connect(client_options)?;
198 Ok(Some(Arc::new(WorkloadIdentityCredentialProvider::new(
199 application_credentials_path.clone(),
200 http_client,
201 ))))
202}
203
204impl GcsStoreProvider {
205 async fn build_opendal_gcs_store(
206 &self,
207 base_path: &Url,
208 storage_options: &StorageOptions,
209 ) -> Result<Arc<dyn OSObjectStore>> {
210 let bucket = base_path
211 .host_str()
212 .ok_or_else(|| Error::invalid_input("GCS URL must contain bucket name"))?
213 .to_string();
214
215 let prefix = base_path.path().trim_start_matches('/').to_string();
216
217 let mut config_map: HashMap<String, String> = storage_options.0.clone();
220
221 config_map.insert("bucket".to_string(), bucket);
223
224 if !prefix.is_empty() {
225 config_map.insert("root".to_string(), format!("/{}", prefix));
226 }
227
228 let operator = Operator::from_iter::<Gcs>(config_map)
229 .map_err(|e| Error::invalid_input(format!("Failed to create GCS operator: {:?}", e)))?;
230
231 Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
232 }
233
234 async fn build_google_cloud_store(
235 &self,
236 base_path: &Url,
237 storage_options: &StorageOptions,
238 accessor: Option<Arc<StorageOptionsAccessor>>,
239 throttle_state: Option<&AimdThrottleState>,
240 ) -> Result<Arc<dyn OSObjectStore>> {
241 let retry_config = RetryConfig {
244 backoff: Default::default(),
245 max_retries: storage_options.client_max_retries(),
246 retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
247 };
248
249 let client_options = gcs_client_options(storage_options)?;
250
251 let mut builder = GoogleCloudStorageBuilder::new()
252 .with_url(base_path.as_ref())
253 .with_retry(retry_config)
254 .with_client_options(client_options.object_requests.clone());
255 for (key, value) in storage_options.as_gcs_options() {
256 builder = builder.with_config(key, value);
257 }
258
259 if let Some(credentials) =
260 build_dynamic_credential_provider::<GcpCredential>(accessor).await?
261 {
262 builder = builder.with_credentials(credentials);
263 } else if let Some(storage_token) = storage_options.get("google_storage_token") {
264 let credential = GcpCredential {
265 bearer: storage_token.clone(),
266 };
267 let credential_provider = Arc::new(StaticCredentialProvider::new(credential)) as _;
268 builder = builder.with_credentials(credential_provider);
269 } else if let Some(credential_provider) = workload_identity_credential_provider(
270 storage_options,
271 &client_options.credential_requests,
272 )? {
273 builder = builder.with_credentials(credential_provider);
276 }
277
278 let store_prefix =
279 self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
280 builder = builder.with_http_connector(cloud_http_connector(throttle_state, store_prefix));
281
282 Ok(Arc::new(builder.build()?) as Arc<dyn OSObjectStore>)
283 }
284}
285
286#[async_trait::async_trait]
287impl ObjectStoreProvider for GcsStoreProvider {
288 async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result<ObjectStore> {
289 let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
290 let mut storage_options =
291 StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
292 storage_options.with_env_gcs();
293 let download_retry_count = storage_options.download_retry_count();
294
295 let use_opendal = storage_options
296 .0
297 .get("use_opendal")
298 .map(|v| v.as_str() == "true")
299 .unwrap_or(false);
300
301 let accessor = params.get_accessor();
302
303 let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?;
304 let throttle_state = if throttle_config.is_disabled() {
305 None
306 } else {
307 Some(AimdThrottleState::new(throttle_config)?)
308 };
309
310 let inner = if use_opendal {
311 self.build_opendal_gcs_store(&base_path, &storage_options)
314 .await?
315 } else {
316 self.build_google_cloud_store(
317 &base_path,
318 &storage_options,
319 accessor,
320 throttle_state.as_ref(),
321 )
322 .await?
323 };
324 let inner = if let Some(throttle_state) = throttle_state {
325 Arc::new(AimdThrottledStore::new_with_state(
326 inner,
327 throttle_state,
328 !use_opendal,
329 )) as Arc<dyn OSObjectStore>
330 } else {
331 inner
332 };
333
334 Ok(ObjectStore {
335 inner,
336 local_dir_operations: None,
337 scheme: String::from("gs"),
338 block_size,
339 max_iop_size: *DEFAULT_MAX_IOP_SIZE,
340 use_constant_size_upload_parts: false,
341 list_is_lexically_ordered: true,
342 io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
343 download_retry_count,
344 io_tracker: Default::default(),
345 store_prefix: self
346 .calculate_object_store_prefix(&base_path, params.storage_options())?,
347 })
348 }
349}
350
351impl StorageOptions {
352 pub fn with_env_gcs(&mut self) {
354 for (os_key, os_value) in std::env::vars_os() {
355 if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str()) {
356 let lowercase_key = key.to_ascii_lowercase();
357 let token_key = "google_storage_token";
358
359 if let Ok(config_key) = GoogleConfigKey::from_str(&lowercase_key) {
360 if !self.0.contains_key(config_key.as_ref()) {
361 self.0
362 .insert(config_key.as_ref().to_string(), value.to_string());
363 }
364 }
365 else if lowercase_key == token_key && !self.0.contains_key(token_key) {
367 self.0.insert(token_key.to_string(), value.to_string());
368 }
369 }
370 }
371 }
372
373 pub fn as_gcs_options(&self) -> HashMap<GoogleConfigKey, String> {
375 self.0
376 .iter()
377 .filter_map(|(key, value)| {
378 let gcs_key = GoogleConfigKey::from_str(&key.to_ascii_lowercase()).ok()?;
379 Some((gcs_key, value.clone()))
380 })
381 .collect()
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use std::{collections::HashMap, fs, sync::Arc};
389
390 use crate::object_store::test_utils::StaticMockStorageOptionsProvider;
391 use crate::object_store::{ObjectStoreParams, StorageOptionsAccessor};
392 use tempfile::TempDir;
393 use wiremock::{
394 Mock, MockServer, ResponseTemplate,
395 matchers::{method, path},
396 };
397
398 fn external_account_storage_options(
399 temp_dir: &TempDir,
400 token_url: String,
401 ) -> HashMap<String, String> {
402 let subject_token_path = temp_dir.path().join("oidc-token");
403 fs::write(&subject_token_path, "github-oidc-token").unwrap();
404 let application_credentials_path = temp_dir.path().join("credentials.json");
405 fs::write(
406 &application_credentials_path,
407 serde_json::to_vec(&serde_json::json!({
408 "type": "external_account",
409 "audience": "test-audience",
410 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
411 "token_url": token_url,
412 "credential_source": {
413 "file": subject_token_path.to_string_lossy(),
414 "format": { "type": "text" }
415 }
416 }))
417 .unwrap(),
418 )
419 .unwrap();
420
421 HashMap::from([
422 (
423 "google_application_credentials".to_string(),
424 application_credentials_path.to_string_lossy().into_owned(),
425 ),
426 ("allow_http".to_string(), "true".to_string()),
427 ])
428 }
429
430 #[test]
431 fn test_gcs_store_path() {
432 let provider = GcsStoreProvider;
433
434 let url = Url::parse("gs://bucket/path/to/file").unwrap();
435 let path = provider.extract_path(&url).unwrap();
436 let expected_path = object_store::path::Path::from("path/to/file");
437 assert_eq!(path, expected_path);
438 }
439
440 #[tokio::test]
441 async fn test_use_opendal_flag() {
442 let provider = GcsStoreProvider;
443 let url = Url::parse("gs://test-bucket/path").unwrap();
444 let params_with_flag = ObjectStoreParams {
445 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
446 HashMap::from([
447 ("use_opendal".to_string(), "true".to_string()),
448 (
449 "service_account".to_string(),
450 "test@example.iam.gserviceaccount.com".to_string(),
451 ),
452 ]),
453 ))),
454 ..Default::default()
455 };
456
457 let store = provider
458 .new_store(url.clone(), ¶ms_with_flag)
459 .await
460 .unwrap();
461 assert_eq!(store.scheme, "gs");
462 }
463
464 #[tokio::test]
465 async fn test_dynamic_gcp_credentials_provider() {
466 let accessor = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
467 StaticMockStorageOptionsProvider {
468 options: HashMap::from([(
469 "google_storage_token".to_string(),
470 "gcp-token".to_string(),
471 )]),
472 },
473 )));
474
475 let credentials = build_dynamic_credential_provider::<GcpCredential>(Some(accessor))
476 .await
477 .expect("dynamic gcp credentials should build")
478 .expect("expected credential provider")
479 .get_credential()
480 .await
481 .expect("expected gcp credential");
482
483 assert_eq!(credentials.bearer, "gcp-token");
484 }
485
486 #[tokio::test]
487 async fn test_external_account_application_credentials() {
488 let mock_server = MockServer::start().await;
489 Mock::given(method("POST"))
490 .and(path("/token"))
491 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
492 "access_token": "federated-token",
493 "expires_in": 3600
494 })))
495 .expect(1)
496 .mount(&mock_server)
497 .await;
498
499 let temp_dir = tempfile::tempdir().unwrap();
500 let mut storage_options =
501 external_account_storage_options(&temp_dir, format!("{}/token", mock_server.uri()));
502 storage_options.insert(
503 "headers.Authorization".to_string(),
504 "Bearer storage-secret".to_string(),
505 );
506 let params = ObjectStoreParams {
507 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
508 storage_options.clone(),
509 ))),
510 ..Default::default()
511 };
512
513 let store = GcsStoreProvider
514 .new_store(Url::parse("gs://test-bucket/path").unwrap(), ¶ms)
515 .await
516 .expect("external account credentials should build a GCS store");
517 assert_eq!(store.scheme, "gs");
518
519 let storage_options = StorageOptions::new(storage_options);
520 let client_options = gcs_client_options(&storage_options).unwrap();
521 let credential_provider = workload_identity_credential_provider(
522 &storage_options,
523 &client_options.credential_requests,
524 )
525 .expect("external account credential provider should build")
526 .expect("external account credentials should select the reqsign provider");
527 for _ in 0..2 {
528 let credential = credential_provider
529 .get_credential()
530 .await
531 .expect("workload identity token exchange should succeed");
532 assert_eq!(credential.bearer, "federated-token");
533 }
534 mock_server.verify().await;
535 let requests = mock_server.received_requests().await.unwrap();
536 assert!(!requests[0].headers.contains_key("authorization"));
537 }
538
539 #[tokio::test]
540 async fn test_external_account_respects_client_timeout() {
541 let mock_server = MockServer::start().await;
542 Mock::given(method("POST"))
543 .and(path("/token"))
544 .respond_with(
545 ResponseTemplate::new(200)
546 .set_delay(Duration::from_secs(1))
547 .set_body_json(serde_json::json!({
548 "access_token": "federated-token",
549 "expires_in": 3600
550 })),
551 )
552 .expect(1)
553 .mount(&mock_server)
554 .await;
555
556 let temp_dir = tempfile::tempdir().unwrap();
557 let mut storage_options =
558 external_account_storage_options(&temp_dir, format!("{}/token", mock_server.uri()));
559 storage_options.insert("timeout".to_string(), "50ms".to_string());
560 let storage_options = StorageOptions::new(storage_options);
561 let client_options = gcs_client_options(&storage_options).unwrap();
562 let credential_provider = workload_identity_credential_provider(
563 &storage_options,
564 &client_options.credential_requests,
565 )
566 .expect("external account credential provider should build")
567 .expect("external account credentials should select the reqsign provider");
568
569 let credential_result = tokio::time::timeout(
570 Duration::from_millis(200),
571 credential_provider.get_credential(),
572 )
573 .await
574 .expect("configured client timeout should bound the credential exchange");
575 let error = credential_result.expect_err("the delayed token exchange should time out");
576 assert!(matches!(&error, object_store::Error::Generic { .. }));
577 assert!(
578 error
579 .to_string()
580 .contains("failed to send Google workload identity HTTP request"),
581 "unexpected error: {error}"
582 );
583 mock_server.verify().await;
584 }
585}