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::{
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    // headers.* options are scoped to object requests and may contain secrets. Credential
155    // exchanges can target unrelated identity endpoints, so only share typed client settings.
156    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        // Start with all storage options as the config map
218        // OpenDAL will handle environment variables through its default credentials chain
219        let mut config_map: HashMap<String, String> = storage_options.0.clone();
220
221        // Set required OpenDAL configuration
222        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        // Use a low retry count since the AIMD throttle layer handles
242        // throttle recovery with its own retry loop.
243        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            // object_store cannot exchange external-account ADC files, while reqsign supports
274            // the workload identity format emitted by google-github-actions/auth.
275            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            // OpenDAL GCS intentionally uses static/environment-backed configuration only.
312            // Namespace-vended dynamic credentials are supported on the native object_store path.
313            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    /// Add values from the environment to storage options
353    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                // Check for GOOGLE_STORAGE_TOKEN until GoogleConfigKey supports storage token
366                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    /// Subset of options relevant for gcs storage
374    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(), &params_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(), &params)
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}