Skip to main content

object_store/gcp/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! An object store implementation for Google Cloud Storage
19//!
20//! See the [Feature Flags](crate#feature-flags) section for the difference
21//! between the `gcp` and `gcp-base` features.
22//!
23//! ## Multipart uploads
24//!
25//! [Multipart uploads](https://cloud.google.com/storage/docs/multipart-uploads)
26//! can be initiated with the [`ObjectStore::put_multipart_opts`] method. If neither
27//! [`MultipartUpload::complete`] nor [`MultipartUpload::abort`] is invoked, you may
28//! have parts uploaded to GCS but not used, that you will be charged for. It is recommended
29//! you configure a [lifecycle rule] to abort incomplete multipart uploads after a certain
30//! period of time to avoid being charged for storing partial uploads.
31//!
32//! ## Using HTTP/2
33//!
34//! Google Cloud Storage supports both HTTP/2 and HTTP/1. HTTP/1 is used by default
35//! because it allows much higher throughput in our benchmarks (see
36//! [#5194](https://github.com/apache/arrow-rs/issues/5194)). HTTP/2 can be
37//! enabled by setting [crate::ClientConfigKey::Http1Only] to false.
38//!
39//! [lifecycle rule]: https://cloud.google.com/storage/docs/lifecycle#abort-mpu
40use std::sync::Arc;
41use std::time::Duration;
42
43use crate::CopyOptions;
44use crate::client::{CredentialProvider, crypto_provider};
45use crate::gcp::credential::GCSAuthorizer;
46use crate::retry::{MultipartRetry, RetryPolicy};
47use crate::signer::{SignedUrlOptions, Signer};
48use crate::util::validate_signed_url_extras;
49use crate::{
50    GetOptions, GetResult, ListResult, MultipartId, MultipartUpload, ObjectMeta, ObjectStore,
51    PutMultipartOptions, PutOptions, PutPayload, PutResult, Result, UploadPart, multipart::PartId,
52    path::Path,
53};
54use async_trait::async_trait;
55use client::GoogleCloudStorageClient;
56use futures_util::stream::{BoxStream, StreamExt};
57use http::Method;
58use url::Url;
59
60use crate::client::get::GetClientExt;
61use crate::client::list::{ListClient, ListClientExt};
62use crate::client::parts::Parts;
63use crate::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore};
64use crate::multipart::MultipartStore;
65pub use builder::{GoogleCloudStorageBuilder, GoogleConfigKey};
66pub use credential::{GcpCredential, GcpSigningCredential, ServiceAccountKey};
67
68mod builder;
69mod client;
70mod credential;
71
72const STORE: &str = "GCS";
73
74/// [`CredentialProvider`] for [`GoogleCloudStorage`]
75pub type GcpCredentialProvider = Arc<dyn CredentialProvider<Credential = GcpCredential>>;
76
77/// [`GcpSigningCredential`] for [`GoogleCloudStorage`]
78pub type GcpSigningCredentialProvider =
79    Arc<dyn CredentialProvider<Credential = GcpSigningCredential>>;
80
81/// Interface for [Google Cloud Storage](https://cloud.google.com/storage/).
82#[derive(Debug, Clone)]
83pub struct GoogleCloudStorage {
84    client: Arc<GoogleCloudStorageClient>,
85}
86
87impl std::fmt::Display for GoogleCloudStorage {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(
90            f,
91            "GoogleCloudStorage({})",
92            self.client.config().bucket_name
93        )
94    }
95}
96
97impl GoogleCloudStorage {
98    /// Returns the [`GcpCredentialProvider`] used by [`GoogleCloudStorage`]
99    pub fn credentials(&self) -> &GcpCredentialProvider {
100        &self.client.config().credentials
101    }
102
103    /// Returns the [`GcpSigningCredentialProvider`] used by [`GoogleCloudStorage`]
104    pub fn signing_credentials(&self) -> &GcpSigningCredentialProvider {
105        &self.client.config().signing_credentials
106    }
107}
108
109#[derive(Debug)]
110struct GCSMultipartUpload {
111    state: Arc<UploadState>,
112    part_idx: usize,
113}
114
115#[derive(Debug)]
116struct UploadState {
117    client: Arc<GoogleCloudStorageClient>,
118    path: Path,
119    multipart_id: MultipartId,
120    parts: Parts,
121    retry_policy: Option<Arc<dyn RetryPolicy>>,
122}
123
124#[async_trait]
125impl MultipartUpload for GCSMultipartUpload {
126    fn put_part(&mut self, payload: PutPayload) -> UploadPart {
127        let idx = self.part_idx;
128        self.part_idx += 1;
129        let state = Arc::clone(&self.state);
130        Box::pin(async move {
131            let mut retry = MultipartRetry::new(state.retry_policy.clone());
132            loop {
133                match state
134                    .client
135                    .put_part(&state.path, &state.multipart_id, idx, payload.clone())
136                    .await
137                {
138                    Ok(part) => {
139                        state.parts.put(idx, part);
140                        return Ok(());
141                    }
142                    Err(error) if retry.should_retry(&error).await => continue,
143                    Err(error) => return Err(error),
144                }
145            }
146        })
147    }
148
149    async fn complete(&mut self) -> Result<PutResult> {
150        let parts = self.state.parts.finish(self.part_idx)?;
151
152        self.state
153            .client
154            .multipart_complete(&self.state.path, &self.state.multipart_id, parts)
155            .await
156    }
157
158    async fn abort(&mut self) -> Result<()> {
159        self.state
160            .client
161            .multipart_cleanup(&self.state.path, &self.state.multipart_id)
162            .await
163    }
164}
165
166#[async_trait]
167impl ObjectStore for GoogleCloudStorage {
168    async fn put_opts(
169        &self,
170        location: &Path,
171        payload: PutPayload,
172        opts: PutOptions,
173    ) -> Result<PutResult> {
174        self.client.put(location, payload, opts).await
175    }
176
177    async fn put_multipart_opts(
178        &self,
179        location: &Path,
180        opts: PutMultipartOptions,
181    ) -> Result<Box<dyn MultipartUpload>> {
182        let retry_policy = opts.retry_policy();
183        let upload_id = self.client.multipart_initiate(location, opts).await?;
184
185        Ok(Box::new(GCSMultipartUpload {
186            part_idx: 0,
187            state: Arc::new(UploadState {
188                client: Arc::clone(&self.client),
189                path: location.clone(),
190                multipart_id: upload_id.clone(),
191                parts: Default::default(),
192                retry_policy,
193            }),
194        }))
195    }
196
197    async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
198        self.client.get_opts(location, options).await
199    }
200
201    fn delete_stream(
202        &self,
203        locations: BoxStream<'static, Result<Path>>,
204    ) -> BoxStream<'static, Result<Path>> {
205        let client = Arc::clone(&self.client);
206        locations
207            .map(move |location| {
208                let client = Arc::clone(&client);
209                async move {
210                    let location = location?;
211                    client.delete_request(&location).await?;
212                    Ok(location)
213                }
214            })
215            .buffered(10)
216            .boxed()
217    }
218
219    fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
220        self.client.list(prefix)
221    }
222
223    fn list_with_offset(
224        &self,
225        prefix: Option<&Path>,
226        offset: &Path,
227    ) -> BoxStream<'static, Result<ObjectMeta>> {
228        self.client.list_with_offset(prefix, offset)
229    }
230
231    async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
232        self.client.list_with_delimiter(prefix).await
233    }
234
235    async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> {
236        let CopyOptions {
237            mode,
238            extensions: _,
239        } = options;
240
241        self.client.copy_request(from, to, mode).await
242    }
243}
244
245#[async_trait]
246impl MultipartStore for GoogleCloudStorage {
247    /// Create a new multipart upload, returning its [`MultipartId`].
248    ///
249    /// This is the low-level [`MultipartStore`] API, which gives direct control
250    /// over individual parts. See [`ObjectStoreExt::put_multipart`] for a
251    /// higher-level API that handles parts automatically.
252    ///
253    /// # Example
254    ///
255    /// Create a multipart upload, upload two parts, and finalize it by calling
256    /// [`complete_multipart`]:
257    ///
258    /// ```no_run
259    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
260    /// # use object_store::{gcp::GoogleCloudStorageBuilder, multipart::MultipartStore, path::Path, PutPayload};
261    /// #
262    /// let gcs = GoogleCloudStorageBuilder::new()
263    ///     .with_bucket_name("my-bucket")
264    ///     .with_service_account_path("/path/to/service-account.json")
265    ///     .build()?;
266    ///
267    /// let path = Path::from("data/large_file");
268    ///
269    /// // Start the upload, obtaining an id used to reference it in later calls
270    /// let id = gcs.create_multipart(&path).await?;
271    ///
272    /// // Upload the individual parts. Every part except the last must be at least
273    /// // 5 MiB.
274    /// let part0 = gcs
275    ///     .put_part(&path, &id, 0, PutPayload::from(vec![0; 5 * 1024 * 1024]))
276    ///     .await?;
277    /// let part1 = gcs
278    ///     .put_part(&path, &id, 1, PutPayload::from("the final part"))
279    ///     .await?;
280    ///
281    /// // Finalize the upload. The parts must be provided in `part_idx` order.
282    /// gcs.complete_multipart(&path, &id, vec![part0, part1]).await?;
283    /// #     Ok(())
284    /// # }
285    /// ```
286    ///
287    /// Every part except the last must be at least 5 MiB. Parts may be uploaded
288    /// concurrently and in any order, provided each is given the correct
289    /// `part_idx`. See [Cloud Storage multipart upload limits] for the full set of
290    /// size and count constraints.
291    ///
292    /// To discard an upload instead of completing it, call
293    /// [`abort_multipart`] with the same `id`.
294    ///
295    /// [`ObjectStoreExt::put_multipart`]: crate::ObjectStoreExt::put_multipart
296    /// [`complete_multipart`]: MultipartStore::complete_multipart
297    /// [`abort_multipart`]: MultipartStore::abort_multipart
298    /// [Cloud Storage multipart upload limits]: https://docs.cloud.google.com/storage/docs/xml-api/post-object-complete
299    async fn create_multipart(&self, path: &Path) -> Result<MultipartId> {
300        self.client
301            .multipart_initiate(path, PutMultipartOptions::default())
302            .await
303    }
304
305    async fn create_multipart_opts(
306        &self,
307        path: &Path,
308        opts: PutMultipartOptions,
309    ) -> Result<MultipartId> {
310        self.client.multipart_initiate(path, opts).await
311    }
312
313    async fn put_part(
314        &self,
315        path: &Path,
316        id: &MultipartId,
317        part_idx: usize,
318        payload: PutPayload,
319    ) -> Result<PartId> {
320        self.client.put_part(path, id, part_idx, payload).await
321    }
322
323    async fn complete_multipart(
324        &self,
325        path: &Path,
326        id: &MultipartId,
327        parts: Vec<PartId>,
328    ) -> Result<PutResult> {
329        self.client.multipart_complete(path, id, parts).await
330    }
331
332    async fn abort_multipart(&self, path: &Path, id: &MultipartId) -> Result<()> {
333        self.client.multipart_cleanup(path, id).await
334    }
335}
336
337#[async_trait]
338impl Signer for GoogleCloudStorage {
339    async fn signed_url(&self, method: Method, path: &Path, expires_in: Duration) -> Result<Url> {
340        self.signed_url_opts(method, path, expires_in, &SignedUrlOptions::default())
341            .await
342    }
343
344    /// Create a signed URL, additionally folding the query parameters and headers in `options`
345    /// into the signature.
346    ///
347    /// `extra_query` lets callers sign query parameters the recipient must send, and
348    /// `signed_headers` binds request headers (e.g. `content-type`) to the signature; the
349    /// recipient must send exactly these headers and values.
350    async fn signed_url_opts(
351        &self,
352        method: Method,
353        path: &Path,
354        expires_in: Duration,
355        options: &SignedUrlOptions,
356    ) -> Result<Url> {
357        if expires_in.as_secs() > 604800 {
358            return Err(crate::Error::Generic {
359                store: STORE,
360                source: "Expiration Time can't be longer than 604800 seconds (7 days).".into(),
361            });
362        }
363
364        // Validate the caller-provided extras up front, rejecting reserved query parameters and
365        // headers controlled by the signer.
366        validate_signed_url_extras(
367            STORE,
368            &options.extra_query,
369            &options.signed_headers,
370            "x-goog-",
371        )?;
372
373        let config = self.client.config();
374        let path_url = config.path_url(path);
375        let mut url = Url::parse(&path_url).map_err(|e| crate::Error::Generic {
376            store: STORE,
377            source: format!("Unable to parse url {path_url}: {e}").into(),
378        })?;
379
380        let signing_credentials = self.signing_credentials().get_credential().await?;
381        let authorizer = GCSAuthorizer::new(signing_credentials);
382
383        let crypto = crypto_provider(self.client.config().crypto.as_deref())?;
384        authorizer
385            .sign_with(
386                crypto,
387                method,
388                &mut url,
389                &options.extra_query,
390                &options.signed_headers,
391                expires_in,
392                &self.client,
393            )
394            .await?;
395
396        Ok(url)
397    }
398}
399
400#[async_trait]
401impl PaginatedListStore for GoogleCloudStorage {
402    async fn list_paginated(
403        &self,
404        prefix: Option<&str>,
405        opts: PaginatedListOptions,
406    ) -> Result<PaginatedListResult> {
407        self.client.list_request(prefix, opts).await
408    }
409}
410
411#[cfg(test)]
412mod test {
413    use credential::DEFAULT_GCS_BASE_URL;
414
415    use crate::ObjectStoreExt;
416    use crate::integration::*;
417    use crate::tests::*;
418
419    use super::*;
420
421    const NON_EXISTENT_NAME: &str = "nonexistentname";
422
423    #[tokio::test]
424    async fn gcs_test() {
425        maybe_skip_integration!();
426        // tag the extensions of every HTTP response with a marker,
427        // allowing response_extensions to verify their propagation
428        let integration = GoogleCloudStorageBuilder::from_env()
429            .with_http_connector(MarkerHttpConnector::default())
430            .build()
431            .unwrap();
432
433        put_get_delete_list(&integration).await;
434        list_with_offset_exclusivity(&integration).await;
435        list_uses_directories_correctly(&integration).await;
436        list_with_delimiter(&integration).await;
437        rename_and_copy(&integration).await;
438        if integration.client.config().base_url == DEFAULT_GCS_BASE_URL {
439            // Fake GCS server doesn't currently honor ifGenerationMatch
440            // https://github.com/fsouza/fake-gcs-server/issues/994
441            copy_if_not_exists(&integration).await;
442            // Fake GCS server does not yet implement XML Multipart uploads
443            // https://github.com/fsouza/fake-gcs-server/issues/852
444            stream_get(&integration).await;
445            multipart(&integration, &integration).await;
446            multipart_with_opts(&integration, &integration).await;
447            multipart_put_part_out_of_order(&integration, &integration).await;
448            multipart_race_condition(&integration, true).await;
449            multipart_out_of_order(&integration).await;
450            list_paginated(&integration, &integration).await;
451            // Fake GCS server doesn't currently honor preconditions
452            get_opts(&integration).await;
453            put_opts(&integration, true).await;
454            // Fake GCS server doesn't currently support attributes
455            put_get_attributes(&integration).await;
456        }
457
458        // Fake GCS server does not yet implement XML Multipart uploads
459        let test_multipart = integration.client.config().base_url == DEFAULT_GCS_BASE_URL;
460        response_extensions(&integration, test_multipart).await;
461    }
462
463    #[cfg(feature = "reqwest")]
464    #[tokio::test]
465    #[ignore]
466    async fn gcs_test_sign() {
467        maybe_skip_integration!();
468        let integration = GoogleCloudStorageBuilder::from_env().build().unwrap();
469
470        let client = reqwest::Client::new();
471
472        let path = Path::from("test_sign");
473        let url = integration
474            .signed_url(Method::PUT, &path, Duration::from_secs(3600))
475            .await
476            .unwrap();
477        println!("PUT {url}");
478
479        let resp = client.put(url).body("data").send().await.unwrap();
480        resp.error_for_status().unwrap();
481
482        let url = integration
483            .signed_url(Method::GET, &path, Duration::from_secs(3600))
484            .await
485            .unwrap();
486        println!("GET {url}");
487
488        let resp = client.get(url).send().await.unwrap();
489        let resp = resp.error_for_status().unwrap();
490        let data = resp.bytes().await.unwrap();
491        assert_eq!(data.as_ref(), b"data");
492    }
493
494    #[tokio::test]
495    async fn gcs_test_get_nonexistent_location() {
496        maybe_skip_integration!();
497        let integration = GoogleCloudStorageBuilder::from_env().build().unwrap();
498
499        let location = Path::from_iter([NON_EXISTENT_NAME]);
500
501        let err = integration.get(&location).await.unwrap_err();
502
503        assert!(
504            matches!(err, crate::Error::NotFound { .. }),
505            "unexpected error type: {err}"
506        );
507    }
508
509    #[tokio::test]
510    async fn gcs_test_get_nonexistent_bucket() {
511        maybe_skip_integration!();
512        let config = GoogleCloudStorageBuilder::from_env();
513        let integration = config.with_bucket_name(NON_EXISTENT_NAME).build().unwrap();
514
515        let location = Path::from_iter([NON_EXISTENT_NAME]);
516
517        let err = get_nonexistent_object(&integration, Some(location))
518            .await
519            .unwrap_err();
520
521        assert!(
522            matches!(err, crate::Error::NotFound { .. }),
523            "unexpected error type: {err}"
524        );
525    }
526
527    #[tokio::test]
528    async fn gcs_test_delete_nonexistent_location() {
529        maybe_skip_integration!();
530        let integration = GoogleCloudStorageBuilder::from_env().build().unwrap();
531
532        let location = Path::from_iter([NON_EXISTENT_NAME]);
533
534        let err = integration.delete(&location).await.unwrap_err();
535        assert!(
536            matches!(err, crate::Error::NotFound { .. }),
537            "unexpected error type: {err}"
538        );
539    }
540
541    #[tokio::test]
542    async fn gcs_test_delete_nonexistent_bucket() {
543        maybe_skip_integration!();
544        let config = GoogleCloudStorageBuilder::from_env();
545        let integration = config.with_bucket_name(NON_EXISTENT_NAME).build().unwrap();
546
547        let location = Path::from_iter([NON_EXISTENT_NAME]);
548
549        let err = integration.delete(&location).await.unwrap_err();
550        assert!(
551            matches!(err, crate::Error::NotFound { .. }),
552            "unexpected error type: {err}"
553        );
554    }
555
556    #[tokio::test]
557    async fn gcs_test_put_nonexistent_bucket() {
558        maybe_skip_integration!();
559        let config = GoogleCloudStorageBuilder::from_env();
560        let integration = config.with_bucket_name(NON_EXISTENT_NAME).build().unwrap();
561
562        let location = Path::from_iter([NON_EXISTENT_NAME]);
563        let data = PutPayload::from("arbitrary data");
564
565        let err = integration
566            .put(&location, data)
567            .await
568            .unwrap_err()
569            .to_string();
570        assert!(
571            err.contains("Server returned non-2xx status code: 404 Not Found"),
572            "{}",
573            err
574        )
575    }
576}