Skip to main content

lance_io/object_store/providers/
gcp.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
5
6use object_store::list::PaginatedListStore;
7use object_store::{
8    ClientOptions, CredentialProvider, ObjectStore as OSObjectStore, Result as ObjectStoreResult,
9    client::{HttpClient, HttpConnector, HttpRequestBody, ReqwestConnector},
10};
11use opendal::{Operator, services::Gcs};
12use reqsign_core::{Context as ReqsignContext, HttpSend, OsEnv, ProvideCredential};
13use reqsign_file_read_tokio::TokioFileRead;
14use reqsign_google::{Credential as ReqsignCredential, FileCredentialProvider};
15use tokio::sync::RwLock;
16
17use object_store::{
18    RetryConfig, StaticCredentialProvider,
19    gcp::{GcpCredential, GoogleCloudStorage, GoogleCloudStorageBuilder, GoogleConfigKey},
20};
21use url::Url;
22
23use crate::object_store::opendal_store::OpendalStore;
24use crate::object_store::{
25    DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
26    ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
27    dynamic_credentials::build_dynamic_credential_provider,
28    throttle::{AimdThrottleState, cloud_http_connector, shared_throttle_state, with_throttling},
29};
30use lance_core::error::{Error, Result};
31use lance_core::utils::parse::str_is_truthy;
32#[derive(Default, Debug)]
33pub struct GcsStoreProvider;
34
35#[derive(Debug)]
36struct ObjectStoreHttpSend {
37    client: HttpClient,
38}
39
40impl HttpSend for ObjectStoreHttpSend {
41    async fn http_send(
42        &self,
43        request: http::Request<bytes::Bytes>,
44    ) -> reqsign_core::Result<http::Response<bytes::Bytes>> {
45        let (parts, body) = request.into_parts();
46        let request = http::Request::from_parts(parts, HttpRequestBody::from(body));
47        let response = self.client.execute(request).await.map_err(|source| {
48            reqsign_core::Error::unexpected("failed to send Google workload identity HTTP request")
49                .with_source(source)
50        })?;
51        let (parts, body) = response.into_parts();
52        let body = body.bytes().await.map_err(|source| {
53            reqsign_core::Error::unexpected("failed to read Google workload identity HTTP response")
54                .with_source(source)
55        })?;
56        Ok(http::Response::from_parts(parts, body))
57    }
58}
59
60#[derive(Debug)]
61struct WorkloadIdentityCredentialProvider {
62    provider: FileCredentialProvider,
63    context: ReqsignContext,
64    cached_credential: RwLock<Option<ReqsignCredential>>,
65}
66
67impl WorkloadIdentityCredentialProvider {
68    fn new(application_credentials_path: String, http_client: HttpClient) -> Self {
69        let context = ReqsignContext::new()
70            .with_file_read(TokioFileRead)
71            .with_http_send(ObjectStoreHttpSend {
72                client: http_client,
73            })
74            .with_env(OsEnv);
75        Self {
76            provider: FileCredentialProvider::new(application_credentials_path),
77            context,
78            cached_credential: RwLock::new(None),
79        }
80    }
81}
82
83fn usable_gcp_credential(credential: &ReqsignCredential) -> Option<GcpCredential> {
84    credential
85        .token
86        .as_ref()
87        .filter(|_| credential.has_valid_token())
88        .map(|token| GcpCredential {
89            bearer: token.access_token.clone(),
90        })
91}
92
93fn workload_identity_error(
94    source: impl std::error::Error + Send + Sync + 'static,
95) -> object_store::Error {
96    object_store::Error::Generic {
97        store: "GCS workload identity credentials",
98        source: Box::new(source),
99    }
100}
101
102#[async_trait::async_trait]
103impl CredentialProvider for WorkloadIdentityCredentialProvider {
104    type Credential = GcpCredential;
105
106    async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
107        if let Some(credential) = self
108            .cached_credential
109            .read()
110            .await
111            .as_ref()
112            .and_then(usable_gcp_credential)
113        {
114            return Ok(Arc::new(credential));
115        }
116
117        let mut cached_credential = self.cached_credential.write().await;
118        if let Some(credential) = cached_credential.as_ref().and_then(usable_gcp_credential) {
119            return Ok(Arc::new(credential));
120        }
121
122        let credential = self
123            .provider
124            .provide_credential(&self.context)
125            .await
126            .map_err(workload_identity_error)?
127            .ok_or_else(|| {
128                workload_identity_error(std::io::Error::other(
129                    "application credentials did not provide a Google access token",
130                ))
131            })?;
132        let gcp_credential = usable_gcp_credential(&credential).ok_or_else(|| {
133            workload_identity_error(std::io::Error::other(
134                "application credentials provided an expired or unusable Google access token",
135            ))
136        })?;
137        *cached_credential = Some(credential);
138        Ok(Arc::new(gcp_credential))
139    }
140}
141
142#[derive(serde::Deserialize)]
143struct ApplicationCredentialKind {
144    #[serde(rename = "type")]
145    credential_type: String,
146}
147
148struct GcsClientOptions {
149    object_requests: ClientOptions,
150    credential_requests: ClientOptions,
151}
152
153fn gcs_client_options(storage_options: &StorageOptions) -> Result<GcsClientOptions> {
154    let mut object_requests = storage_options.client_options()?;
155    // headers.* options are scoped to object requests and may contain secrets. Credential
156    // exchanges can target unrelated identity endpoints, so only share typed client settings.
157    let mut credential_requests = object_requests
158        .clone()
159        .with_default_headers(Default::default());
160    for (key, value) in storage_options.as_gcs_options() {
161        if let GoogleConfigKey::Client(key) = key {
162            object_requests = object_requests.with_config(key, value.clone());
163            credential_requests = credential_requests.with_config(key, value);
164        }
165    }
166    Ok(GcsClientOptions {
167        object_requests,
168        credential_requests,
169    })
170}
171
172fn workload_identity_credential_provider(
173    storage_options: &StorageOptions,
174    client_options: &ClientOptions,
175) -> Result<Option<Arc<dyn CredentialProvider<Credential = GcpCredential>>>> {
176    let gcs_options = storage_options.as_gcs_options();
177    if gcs_options.contains_key(&GoogleConfigKey::ServiceAccount)
178        || gcs_options.contains_key(&GoogleConfigKey::ServiceAccountKey)
179    {
180        return Ok(None);
181    }
182
183    let Some(application_credentials_path) =
184        gcs_options.get(&GoogleConfigKey::ApplicationCredentials)
185    else {
186        return Ok(None);
187    };
188    let Ok(contents) = std::fs::read(application_credentials_path) else {
189        return Ok(None);
190    };
191    let Ok(credential_kind) = serde_json::from_slice::<ApplicationCredentialKind>(&contents) else {
192        return Ok(None);
193    };
194    if credential_kind.credential_type != "external_account" {
195        return Ok(None);
196    }
197
198    let http_client = ReqwestConnector::default().connect(client_options)?;
199    Ok(Some(Arc::new(WorkloadIdentityCredentialProvider::new(
200        application_credentials_path.clone(),
201        http_client,
202    ))))
203}
204
205impl GcsStoreProvider {
206    async fn build_opendal_gcs_store(
207        &self,
208        base_path: &Url,
209        storage_options: &StorageOptions,
210    ) -> Result<Arc<dyn OSObjectStore>> {
211        let bucket = base_path
212            .host_str()
213            .ok_or_else(|| Error::invalid_input("GCS URL must contain bucket name"))?
214            .to_string();
215
216        let prefix = base_path.path().trim_start_matches('/').to_string();
217
218        // Start with all storage options as the config map
219        // OpenDAL will handle environment variables through its default credentials chain
220        let mut config_map: HashMap<String, String> = storage_options.0.clone();
221
222        // Set required OpenDAL configuration
223        config_map.insert("bucket".to_string(), bucket);
224
225        if !prefix.is_empty() {
226            config_map.insert("root".to_string(), format!("/{}", prefix));
227        }
228
229        let operator = Operator::from_iter::<Gcs>(config_map)
230            .map_err(|e| Error::invalid_input(format!("Failed to create GCS operator: {:?}", e)))?;
231
232        Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
233    }
234
235    async fn build_google_cloud_store(
236        &self,
237        base_path: &Url,
238        storage_options: &StorageOptions,
239        accessor: Option<Arc<StorageOptionsAccessor>>,
240        throttle_state: Option<&AimdThrottleState>,
241        // Concrete rather than `dyn`, so the caller keeps the handle a paginated listing
242        // needs: `PaginatedListStore` is a separate trait from `ObjectStore`.
243    ) -> Result<Arc<GoogleCloudStorage>> {
244        // Use a low retry count since the AIMD throttle layer handles
245        // throttle recovery with its own retry loop.
246        let retry_config = RetryConfig {
247            backoff: Default::default(),
248            max_retries: storage_options.client_max_retries(),
249            retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
250        };
251
252        let client_options = gcs_client_options(storage_options)?;
253
254        let mut builder = GoogleCloudStorageBuilder::new()
255            .with_url(base_path.as_ref())
256            .with_retry(retry_config)
257            .with_client_options(client_options.object_requests.clone());
258        for (key, value) in storage_options.as_gcs_options() {
259            builder = builder.with_config(key, value);
260        }
261
262        if let Some(credentials) =
263            build_dynamic_credential_provider::<GcpCredential>(accessor).await?
264        {
265            builder = builder.with_credentials(credentials);
266        } else if let Some(storage_token) = storage_options.get("google_storage_token") {
267            let credential = GcpCredential {
268                bearer: storage_token.clone(),
269            };
270            let credential_provider = Arc::new(StaticCredentialProvider::new(credential)) as _;
271            builder = builder.with_credentials(credential_provider);
272        } else if let Some(credential_provider) = workload_identity_credential_provider(
273            storage_options,
274            &client_options.credential_requests,
275        )? {
276            // object_store cannot exchange external-account ADC files, while reqsign supports
277            // the workload identity format emitted by google-github-actions/auth.
278            builder = builder.with_credentials(credential_provider);
279        }
280
281        let store_prefix =
282            self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
283        builder = builder.with_http_connector(cloud_http_connector(
284            throttle_state,
285            crate::object_store::metrics_base(&store_prefix, base_path),
286        ));
287
288        Ok(Arc::new(builder.build()?))
289    }
290}
291
292#[async_trait::async_trait]
293impl ObjectStoreProvider for GcsStoreProvider {
294    async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result<ObjectStore> {
295        let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
296        let mut storage_options =
297            StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
298        storage_options.with_env_gcs();
299        let download_retry_count = storage_options.download_retry_count();
300
301        let use_opendal = storage_options
302            .0
303            .get("use_opendal")
304            .map(|v| str_is_truthy(v.as_str()))
305            .unwrap_or(false);
306
307        let accessor = params.get_accessor();
308
309        // Keyed like the registry cache so per-dataset stores share the bucket's budget.
310        let store_prefix =
311            self.calculate_object_store_prefix(&base_path, params.storage_options())?;
312        let throttle_state = shared_throttle_state(&store_prefix, params)?;
313
314        let (inner, paginated_lister) = if use_opendal {
315            // OpenDAL GCS intentionally uses static/environment-backed configuration only.
316            // Namespace-vended dynamic credentials are supported on the native object_store path.
317            // Listed in full: no paginated lister covers OpenDAL yet.
318            (
319                self.build_opendal_gcs_store(&base_path, &storage_options)
320                    .await?,
321                None,
322            )
323        } else {
324            let store = self
325                .build_google_cloud_store(
326                    &base_path,
327                    &storage_options,
328                    accessor,
329                    throttle_state.as_ref(),
330                )
331                .await?;
332            (
333                store.clone() as Arc<dyn OSObjectStore>,
334                Some(store as Arc<dyn PaginatedListStore>),
335            )
336        };
337        let (inner, paginated_lister) =
338            with_throttling(throttle_state, !use_opendal, inner, paginated_lister);
339
340        Ok(ObjectStore {
341            inner,
342            local_dir_operations: None,
343            scheme: String::from("gs"),
344            block_size,
345            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
346            use_constant_size_upload_parts: false,
347            list_is_lexically_ordered: true,
348            io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
349            download_retry_count,
350            io_tracker: Default::default(),
351            store_prefix: self
352                .calculate_object_store_prefix(&base_path, params.storage_options())?,
353            paginated_lister,
354        })
355    }
356}
357
358impl StorageOptions {
359    /// Add values from the environment without overriding explicit options,
360    /// including aliases, case variants, and empty-string values.
361    pub fn with_env_gcs(&mut self) {
362        self.merge_env_options(std::env::vars_os(), |key| {
363            GoogleConfigKey::from_str(key)
364                .ok()
365                .map(|key| key.as_ref().to_string())
366        });
367        // Check for GOOGLE_STORAGE_TOKEN until GoogleConfigKey supports storage token.
368        let token_key = "google_storage_token";
369        for (os_key, os_value) in std::env::vars_os() {
370            if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
371                && key.eq_ignore_ascii_case(token_key)
372                && !self.0.contains_key(token_key)
373            {
374                self.0.insert(token_key.to_string(), value.to_string());
375            }
376        }
377    }
378
379    /// Return options recognized by [`GoogleConfigKey`], ignoring unknown keys.
380    ///
381    /// When aliases conflict, the exact canonical name (such as
382    /// `google_service_account`) wins, followed by case variants of that name.
383    /// Remaining ties use the lexicographically smallest original key. Empty
384    /// values are preserved.
385    ///
386    /// ```
387    /// use std::collections::HashMap;
388    /// use lance_io::object_store::StorageOptions;
389    /// use object_store::gcp::GoogleConfigKey;
390    ///
391    /// let options = StorageOptions(HashMap::from([
392    ///     ("service_account".into(), "alias.json".into()),
393    ///     ("google_service_account".into(), "canonical.json".into()),
394    /// ]));
395    /// assert_eq!(options.as_gcs_options()[&GoogleConfigKey::ServiceAccount], "canonical.json");
396    /// ```
397    pub fn as_gcs_options(&self) -> HashMap<GoogleConfigKey, String> {
398        self.as_cloud_options()
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use std::{collections::HashMap, fs, sync::Arc};
406
407    use crate::object_store::test_utils::StaticMockStorageOptionsProvider;
408    use crate::object_store::{ObjectStoreParams, StorageOptionsAccessor};
409    use tempfile::TempDir;
410    use wiremock::{
411        Mock, MockServer, ResponseTemplate,
412        matchers::{method, path},
413    };
414
415    #[rstest::rstest]
416    #[case::canonical("google_service_account", "service_account")]
417    #[case::canonical_over_case_variant("google_service_account", "GOOGLE_SERVICE_ACCOUNT")]
418    #[case::canonical_case_tie("GOOGLE_SERVICE_ACCOUNT", "Google_Service_Account")]
419    #[case::case_variant_over_alias("GOOGLE_SERVICE_ACCOUNT", "service_account_path")]
420    #[case::lexical_alias("service_account", "service_account_path")]
421    #[case::case_alias("SERVICE_ACCOUNT", "service_account")]
422    fn test_storage_options_alias_precedence(
423        #[case] preferred_key: &str,
424        #[case] conflicting_key: &str,
425        #[values("preferred", "")] preferred_value: &str,
426        #[values(false, true)] is_reversed: bool,
427    ) {
428        // Exercise fresh hash seeds as well as both insertion orders.
429        for _ in 0..16 {
430            let mut entries = [
431                (preferred_key.to_string(), preferred_value.to_string()),
432                (conflicting_key.to_string(), "conflicting".to_string()),
433                ("lance_custom_option".to_string(), "ignored".to_string()),
434            ];
435            if is_reversed {
436                entries.reverse();
437            }
438            let options = StorageOptions(HashMap::from(entries));
439            assert_eq!(
440                options.as_gcs_options(),
441                HashMap::from([(GoogleConfigKey::ServiceAccount, preferred_value.to_string())])
442            );
443        }
444    }
445
446    fn external_account_storage_options(
447        temp_dir: &TempDir,
448        token_url: String,
449    ) -> HashMap<String, String> {
450        let subject_token_path = temp_dir.path().join("oidc-token");
451        fs::write(&subject_token_path, "github-oidc-token").unwrap();
452        let application_credentials_path = temp_dir.path().join("credentials.json");
453        fs::write(
454            &application_credentials_path,
455            serde_json::to_vec(&serde_json::json!({
456                "type": "external_account",
457                "audience": "test-audience",
458                "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
459                "token_url": token_url,
460                "credential_source": {
461                    "file": subject_token_path.to_string_lossy(),
462                    "format": { "type": "text" }
463                }
464            }))
465            .unwrap(),
466        )
467        .unwrap();
468
469        HashMap::from([
470            (
471                "google_application_credentials".to_string(),
472                application_credentials_path.to_string_lossy().into_owned(),
473            ),
474            ("allow_http".to_string(), "true".to_string()),
475        ])
476    }
477
478    #[test]
479    fn test_gcs_store_path() {
480        let provider = GcsStoreProvider;
481
482        let url = Url::parse("gs://bucket/path/to/file").unwrap();
483        let path = provider.extract_path(&url).unwrap();
484        let expected_path = object_store::path::Path::from("path/to/file");
485        assert_eq!(path, expected_path);
486    }
487
488    #[tokio::test]
489    async fn test_use_opendal_flag() {
490        let provider = GcsStoreProvider;
491        let url = Url::parse("gs://test-bucket/path").unwrap();
492        let params_with_flag = ObjectStoreParams {
493            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
494                HashMap::from([
495                    ("use_opendal".to_string(), "true".to_string()),
496                    (
497                        "service_account".to_string(),
498                        "test@example.iam.gserviceaccount.com".to_string(),
499                    ),
500                ]),
501            ))),
502            ..Default::default()
503        };
504
505        let store = provider
506            .new_store(url.clone(), &params_with_flag)
507            .await
508            .unwrap();
509        assert_eq!(store.scheme, "gs");
510    }
511
512    #[tokio::test]
513    async fn test_dynamic_gcp_credentials_provider() {
514        let accessor = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
515            StaticMockStorageOptionsProvider {
516                options: HashMap::from([(
517                    "google_storage_token".to_string(),
518                    "gcp-token".to_string(),
519                )]),
520            },
521        )));
522
523        let credentials = build_dynamic_credential_provider::<GcpCredential>(Some(accessor))
524            .await
525            .expect("dynamic gcp credentials should build")
526            .expect("expected credential provider")
527            .get_credential()
528            .await
529            .expect("expected gcp credential");
530
531        assert_eq!(credentials.bearer, "gcp-token");
532    }
533
534    #[tokio::test]
535    async fn test_external_account_application_credentials() {
536        let mock_server = MockServer::start().await;
537        Mock::given(method("POST"))
538            .and(path("/token"))
539            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
540                "access_token": "federated-token",
541                "expires_in": 3600
542            })))
543            .expect(1)
544            .mount(&mock_server)
545            .await;
546
547        let temp_dir = tempfile::tempdir().unwrap();
548        let mut storage_options =
549            external_account_storage_options(&temp_dir, format!("{}/token", mock_server.uri()));
550        storage_options.insert(
551            "headers.Authorization".to_string(),
552            "Bearer storage-secret".to_string(),
553        );
554        let params = ObjectStoreParams {
555            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
556                storage_options.clone(),
557            ))),
558            ..Default::default()
559        };
560
561        let store = GcsStoreProvider
562            .new_store(Url::parse("gs://test-bucket/path").unwrap(), &params)
563            .await
564            .expect("external account credentials should build a GCS store");
565        assert_eq!(store.scheme, "gs");
566
567        let storage_options = StorageOptions::new(storage_options);
568        let client_options = gcs_client_options(&storage_options).unwrap();
569        let credential_provider = workload_identity_credential_provider(
570            &storage_options,
571            &client_options.credential_requests,
572        )
573        .expect("external account credential provider should build")
574        .expect("external account credentials should select the reqsign provider");
575        for _ in 0..2 {
576            let credential = credential_provider
577                .get_credential()
578                .await
579                .expect("workload identity token exchange should succeed");
580            assert_eq!(credential.bearer, "federated-token");
581        }
582        mock_server.verify().await;
583        let requests = mock_server.received_requests().await.unwrap();
584        assert!(!requests[0].headers.contains_key("authorization"));
585    }
586
587    #[tokio::test]
588    async fn test_external_account_respects_client_timeout() {
589        let mock_server = MockServer::start().await;
590        Mock::given(method("POST"))
591            .and(path("/token"))
592            .respond_with(
593                ResponseTemplate::new(200)
594                    .set_delay(Duration::from_secs(1))
595                    .set_body_json(serde_json::json!({
596                        "access_token": "federated-token",
597                        "expires_in": 3600
598                    })),
599            )
600            .expect(1)
601            .mount(&mock_server)
602            .await;
603
604        let temp_dir = tempfile::tempdir().unwrap();
605        let mut storage_options =
606            external_account_storage_options(&temp_dir, format!("{}/token", mock_server.uri()));
607        storage_options.insert("timeout".to_string(), "50ms".to_string());
608        let storage_options = StorageOptions::new(storage_options);
609        let client_options = gcs_client_options(&storage_options).unwrap();
610        let credential_provider = workload_identity_credential_provider(
611            &storage_options,
612            &client_options.credential_requests,
613        )
614        .expect("external account credential provider should build")
615        .expect("external account credentials should select the reqsign provider");
616
617        let credential_result = tokio::time::timeout(
618            Duration::from_millis(200),
619            credential_provider.get_credential(),
620        )
621        .await
622        .expect("configured client timeout should bound the credential exchange");
623        let error = credential_result.expect_err("the delayed token exchange should time out");
624        assert!(matches!(&error, object_store::Error::Generic { .. }));
625        assert!(
626            error
627                .to_string()
628                .contains("failed to send Google workload identity HTTP request"),
629            "unexpected error: {error}"
630        );
631        mock_server.verify().await;
632    }
633}