Repository: googleapis/google-cloud-ruby (Ruby)

Issue:
storage: add support for object retention
Please implement the object retention feature. The following operations must be supported:

- Create a bucket with object retention enabled by using the `enableObjectRetention` bucket create boolean option at bucket create time
- Get the object retention enabled status of a bucket
- Create an object with retention enabled (on an retention enabled bucket) by populating the `retention` object at object create time (retention object has the fields `mode` which can be Unlocked or Locked, and `retainUntilTime` which is a timestamp)
- Update retention for an existing object with a patch operation, by passing the `overrideUnlockedRetention` boolean patch option
- Get the retention policy of an object
- Remove retention for an existing object the same way as above

Details in go/gcs-object-retention-client-request

You can use the [java implementation](https://github.com/googleapis/java-storage/pull/2277) as a reference.

The kokoro test project should be allowlisted shortly, let me know if you need access to an allowlisted project for your own testing. 

We're aiming to get this in by the November code freeze, let me know if you need any help.



Fix (patch):
diff --git a/google-cloud-storage/google-cloud-storage.gemspec b/google-cloud-storage/google-cloud-storage.gemspec
index 0ceed18fa112..170122f558de 100644
--- a/google-cloud-storage/google-cloud-storage.gemspec
+++ b/google-cloud-storage/google-cloud-storage.gemspec
@@ -20,7 +20,7 @@ Gem::Specification.new do |gem|
 
   gem.add_dependency "google-cloud-core", "~> 1.6"
   gem.add_dependency "google-apis-iamcredentials_v1", "~> 0.1"
-  gem.add_dependency "google-apis-storage_v1", "~> 0.29.0"
+  gem.add_dependency "google-apis-storage_v1", "~> 0.31.0"
   gem.add_dependency "googleauth", ">= 0.16.2", "< 2.a"
   gem.add_dependency "digest-crc", "~> 0.4"
   gem.add_dependency "addressable", "~> 2.8"
diff --git a/google-cloud-storage/lib/google/cloud/storage/bucket.rb b/google-cloud-storage/lib/google/cloud/storage/bucket.rb
index 58988b275ca3..34761e0635a9 100644
--- a/google-cloud-storage/lib/google/cloud/storage/bucket.rb
+++ b/google-cloud-storage/lib/google/cloud/storage/bucket.rb
@@ -118,6 +118,15 @@ def autoclass
           @gapi.autoclass
         end
 
+        ##
+        # The object retention configuration of the bucket
+        #
+        # @return [Google::Apis::StorageV1::Bucket::ObjectRetention]
+        #
+        def object_retention
+          @gapi.object_retention
+        end
+
         ##
         # The name of the bucket.
         #
diff --git a/google-cloud-storage/lib/google/cloud/storage/file.rb b/google-cloud-storage/lib/google/cloud/storage/file.rb
index f5cbad9de8a5..6e27a6161c70 100644
--- a/google-cloud-storage/lib/google/cloud/storage/file.rb
+++ b/google-cloud-storage/lib/google/cloud/storage/file.rb
@@ -825,6 +825,9 @@ def generations
         # @param [Integer] if_metageneration_not_match Makes the operation
         #   conditional on whether the file's current metageneration does not
         #   match the given value.
+        # @param [Boolean] override_unlocked_retention
+        #   Must be true to remove the retention configuration, reduce its unlocked
+        #   retention period, or change its mode from unlocked to locked.
         #
         # @yield [file] a block yielding a delegate object for updating the file
         #
@@ -865,7 +868,8 @@ def update generation: nil,
                    if_generation_match: nil,
                    if_generation_not_match: nil,
                    if_metageneration_match: nil,
-                   if_metageneration_not_match: nil
+                   if_metageneration_not_match: nil,
+                   override_unlocked_retention: nil
           updater = Updater.new gapi
           yield updater
           updater.check_for_changed_metadata!
@@ -875,7 +879,8 @@ def update generation: nil,
                        if_generation_match: if_generation_match,
                        if_generation_not_match: if_generation_not_match,
                        if_metageneration_match: if_metageneration_match,
-                       if_metageneration_not_match: if_metageneration_not_match
+                       if_metageneration_not_match: if_metageneration_not_match,
+                       override_unlocked_retention: override_unlocked_retention
         end
 
         ##
@@ -1560,6 +1565,64 @@ def delete generation: nil,
           true
         end
 
+        # Mode of object level retention configuration.
+        # Valid values are 'Locked' or 'Unlocked'
+        #
+        # @return [String]
+        def retention_mode
+          @gapi.retention&.mode
+        end
+
+        # The earliest time in RFC 3339 UTC "Zulu" format that the object can
+        # be deleted or replaced.
+        #
+        # @return [DateTime]
+        def retention_retain_until_time
+          @gapi.retention&.retain_until_time
+        end
+
+        # A collection of object level retention parameters.
+        # The full list of available options are outlined at the [JSON API docs]
+        # (https://cloud.google.com/storage/docs/json_api/v1/objects/insert#request-body).
+        #
+        # @return [Google::Apis::StorageV1::Object::Retention]
+        def retention
+          @gapi.retention
+        end
+
+        ##
+        # Update method to update retention parameter of an object / file
+        # It accepts params as a Hash of attributes in the following format:
+        #
+        #     { mode: 'Locked|Unlocked', retain_until_time: '2023-12-19T03:22:23+00:00' }
+        #
+        # @param [Hash(String => String)] new_retention_attributes
+        #
+        # @example Update retention parameters for the File / Object
+        #   require "google/cloud/storage"
+        #   storage = Google::Cloud::Storage.new
+        #   bucket = storage.bucket "my-bucket"
+        #   file = bucket.file "avatars/heidi/400x400.png"
+        #   retention_params = { mode: 'Unlocked', retain_until_time: '2023-12-19T03:22:23+00:00'.to_datetime }
+        #   file.retention = retention_params
+        #
+        # @example Update retention parameters for the File / Object with override enabled
+        #   require "google/cloud/storage"
+        #   storage = Google::Cloud::Storage.new
+        #   bucket = storage.bucket "my-bucket"
+        #   file = bucket.file "avatars/heidi/400x400.png"
+        #   retention_params = { mode: 'Unlocked',
+        #                        retain_until_time: '2023-12-19T03:22:23+00:00'.to_datetime,
+        #                        override_unlocked_retention: true }
+        #   file.retention = retention_params
+        #
+        def retention= new_retention_attributes
+          @gapi.retention ||= Google::Apis::StorageV1::Object::Retention.new
+          @gapi.retention.mode = new_retention_attributes[:mode]
+          @gapi.retention.retain_until_time = new_retention_attributes[:retain_until_time]
+          update_gapi! :retention, override_unlocked_retention: new_retention_attributes[:override_unlocked_retention]
+        end
+
         ##
         # Public URL to access the file. If the file is not public, requests to
         # the URL will return an error. (See {File::Acl#public!} and
@@ -2015,7 +2078,8 @@ def update_gapi! attributes,
                          if_generation_match: nil,
                          if_generation_not_match: nil,
                          if_metageneration_match: nil,
-                         if_metageneration_not_match: nil
+                         if_metageneration_not_match: nil,
+                         override_unlocked_retention: nil
           attributes = Array(attributes)
           attributes.flatten!
           return if attributes.empty?
@@ -2044,7 +2108,8 @@ def update_gapi! attributes,
                                        if_generation_not_match: if_generation_not_match,
                                        if_metageneration_match: if_metageneration_match,
                                        if_metageneration_not_match: if_metageneration_not_match,
-                                       user_project: user_project
+                                       user_project: user_project,
+                                       override_unlocked_retention: override_unlocked_retention
                   end
         end
 
diff --git a/google-cloud-storage/lib/google/cloud/storage/project.rb b/google-cloud-storage/lib/google/cloud/storage/project.rb
index ba06fd840bdc..ad470af72f21 100644
--- a/google-cloud-storage/lib/google/cloud/storage/project.rb
+++ b/google-cloud-storage/lib/google/cloud/storage/project.rb
@@ -372,6 +372,8 @@ def bucket bucket_name,
         #   bucket instance and its files.
         #
         #   See also {Bucket#requester_pays=} and {Bucket#requester_pays}.
+        # @param [Boolean] enable_object_retention
+        #   When set to true, object retention is enabled for this bucket.
         #
         # @yield [bucket] a block for configuring the bucket before it is
         #   created
@@ -386,6 +388,13 @@ def bucket bucket_name,
         #
         #   bucket = storage.create_bucket "my-bucket"
         #
+        # @example
+        #   require "google/cloud/storage"
+        #
+        #   storage = Google::Cloud::Storage.new
+        #
+        #   bucket = storage.create_bucket "my-bucket", enable_object_retention: true
+        #
         # @example Configure the bucket in a block:
         #   require "google/cloud/storage"
         #
@@ -416,7 +425,8 @@ def create_bucket bucket_name,
                           versioning: nil,
                           requester_pays: nil,
                           user_project: nil,
-                          autoclass_enabled: false
+                          autoclass_enabled: false,
+                          enable_object_retention: nil
           params = {
             name: bucket_name,
             location: location,
@@ -440,7 +450,8 @@ def create_bucket bucket_name,
           updater.check_for_mutable_lifecycle!
           gapi = service.insert_bucket \
             new_bucket, acl: acl_rule(acl), default_acl: acl_rule(default_acl),
-                        user_project: user_project
+                        user_project: user_project,
+                        enable_object_retention: enable_object_retention
           Bucket.from_gapi gapi, service, user_project: user_project
         end
 
diff --git a/google-cloud-storage/lib/google/cloud/storage/service.rb b/google-cloud-storage/lib/google/cloud/storage/service.rb
index a1e80272390d..f2212943f94a 100644
--- a/google-cloud-storage/lib/google/cloud/storage/service.rb
+++ b/google-cloud-storage/lib/google/cloud/storage/service.rb
@@ -112,14 +112,16 @@ def get_bucket bucket_name,
         # Creates a new bucket.
         # Returns Google::Apis::StorageV1::Bucket.
         def insert_bucket bucket_gapi, acl: nil, default_acl: nil,
-                          user_project: nil, options: {}
+                          user_project: nil, enable_object_retention: nil,
+                          options: {}
           execute do
             service.insert_bucket \
               @project, bucket_gapi,
               predefined_acl: acl,
               predefined_default_object_acl: default_acl,
               user_project: user_project(user_project),
-              options: options
+              options: options,
+              enable_object_retention: enable_object_retention
           end
         end
 
@@ -582,6 +584,7 @@ def patch_file bucket_name,
                        if_metageneration_not_match: nil,
                        predefined_acl: nil,
                        user_project: nil,
+                       override_unlocked_retention: nil,
                        options: {}
           file_gapi ||= Google::Apis::StorageV1::Object.new
 
@@ -601,6 +604,7 @@ def patch_file bucket_name,
                                  if_metageneration_not_match: if_metageneration_not_match,
                                  predefined_acl: predefined_acl,
                                  user_project: user_project(user_project),
+                                 override_unlocked_retention: override_unlocked_retention,
                                  options: options
           end
         end