Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add restore_bucket and handling for soft-deleted buckets #1365

Merged
merged 5 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,9 @@ def reload(
)
if soft_deleted is not None:
query_params["softDeleted"] = soft_deleted
# Soft delete reload requires a generation, even for targets
# that don't include them in default query params (buckets).
query_params["generation"] = self.generation
headers = self._encryption_headers()
_add_etag_match_headers(
headers, if_etag_match=if_etag_match, if_etag_not_match=if_etag_not_match
Expand Down
66 changes: 63 additions & 3 deletions google/cloud/storage/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@ class Bucket(_PropertyMixin):
:type user_project: str
:param user_project: (Optional) the project ID to be billed for API
requests made via this instance.

:type generation: int
:param generation: (Optional) If present, selects a specific revision of
this bucket.
"""

_MAX_OBJECTS_FOR_ITERATION = 256
Expand Down Expand Up @@ -659,7 +663,7 @@ class Bucket(_PropertyMixin):
)
"""Allowed values for :attr:`location_type`."""

def __init__(self, client, name=None, user_project=None):
def __init__(self, client, name=None, user_project=None, generation=None):
"""
property :attr:`name`
Get the bucket's name.
Expand All @@ -672,6 +676,9 @@ def __init__(self, client, name=None, user_project=None):
self._label_removals = set()
self._user_project = user_project

if generation is not None:
self._properties["generation"] = generation

def __repr__(self):
return f"<Bucket: {self.name}>"

Expand Down Expand Up @@ -726,6 +733,50 @@ def user_project(self):
"""
return self._user_project

@property
def generation(self):
"""Retrieve the generation for the bucket.

:rtype: int or ``NoneType``
:returns: The generation of the bucket or ``None`` if the bucket's
resource has not been loaded from the server.
"""
generation = self._properties.get("generation")
if generation is not None:
return int(generation)

@property
def soft_delete_time(self):
"""If this bucket has been soft-deleted, returns the time at which it became soft-deleted.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The time that the bucket became soft-deleted.
Note this property is only set for soft-deleted buckets.
"""
soft_delete_time = self._properties.get("softDeleteTime")
if soft_delete_time is not None:
return _rfc3339_nanos_to_datetime(soft_delete_time)

@property
def hard_delete_time(self):
"""If this bucket has been soft-deleted, returns the time at which it will be permanently deleted.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The time that the bucket will be permanently deleted.
Note this property is only set for soft-deleted buckets.
"""
hard_delete_time = self._properties.get("hardDeleteTime")
if hard_delete_time is not None:
return _rfc3339_nanos_to_datetime(hard_delete_time)

@property
def _query_params(self):
"""Default query parameters."""
params = super()._query_params
return params

@classmethod
def from_string(cls, uri, client=None):
"""Get a constructor for bucket object by URI.
Expand Down Expand Up @@ -1045,6 +1096,7 @@ def reload(
if_metageneration_match=None,
if_metageneration_not_match=None,
retry=DEFAULT_RETRY,
soft_deleted=None,
):
"""Reload properties from Cloud Storage.

Expand Down Expand Up @@ -1084,6 +1136,13 @@ def reload(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type soft_deleted: bool
:param soft_deleted: (Optional) If True, looks for a soft-deleted
bucket. Will only return the bucket metadata if the bucket exists
and is in a soft-deleted state. The bucket ``generation`` must be
set if ``soft_deleted`` is set to True.
See: https://cloud.google.com/storage/docs/soft-delete
"""
super(Bucket, self).reload(
client=client,
Expand All @@ -1094,6 +1153,7 @@ def reload(
if_metageneration_match=if_metageneration_match,
if_metageneration_not_match=if_metageneration_not_match,
retry=retry,
soft_deleted=soft_deleted,
)

@create_trace_span(name="Storage.Bucket.patch")
Expand Down Expand Up @@ -2159,8 +2219,8 @@ def restore_blob(
:param client: (Optional) The client to use. If not passed, falls back
to the ``client`` stored on the current bucket.

:type generation: long
:param generation: (Optional) If present, selects a specific revision of this object.
:type generation: int
:param generation: Selects the specific revision of the object.

:type copy_source_acl: bool
:param copy_source_acl: (Optional) If true, copy the soft-deleted object's access controls.
Expand Down
133 changes: 124 additions & 9 deletions google/cloud/storage/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from google.cloud.client import ClientWithProject
from google.cloud.exceptions import NotFound

from google.cloud.storage._helpers import _add_generation_match_parameters
from google.cloud.storage._helpers import _bucket_bound_hostname_url
from google.cloud.storage._helpers import _get_api_endpoint_override
from google.cloud.storage._helpers import _get_environ_project
Expand Down Expand Up @@ -367,7 +368,7 @@ def get_service_account_email(
api_response = self._get_resource(path, timeout=timeout, retry=retry)
return api_response["email_address"]

def bucket(self, bucket_name, user_project=None):
def bucket(self, bucket_name, user_project=None, generation=None):
"""Factory constructor for bucket object.

.. note::
Expand All @@ -381,10 +382,20 @@ def bucket(self, bucket_name, user_project=None):
:param user_project: (Optional) The project ID to be billed for API
requests made via the bucket.

:type generation: int
:param generation: (Optional) If present, selects a specific revision of
this bucket. Only buckets with soft delete policies
have generations.

:rtype: :class:`google.cloud.storage.bucket.Bucket`
:returns: The bucket object created.
"""
return Bucket(client=self, name=bucket_name, user_project=user_project)
return Bucket(
client=self,
name=bucket_name,
user_project=user_project,
generation=generation,
)

def batch(self, raise_exception=True):
"""Factory constructor for batch object.
Expand Down Expand Up @@ -789,7 +800,7 @@ def _delete_resource(
_target_object=_target_object,
)

def _bucket_arg_to_bucket(self, bucket_or_name):
def _bucket_arg_to_bucket(self, bucket_or_name, generation=None):
"""Helper to return given bucket or create new by name.

Args:
Expand All @@ -798,17 +809,28 @@ def _bucket_arg_to_bucket(self, bucket_or_name):
str, \
]):
The bucket resource to pass or name to create.
generation (Optional[int]):
The bucket generation. Only buckets with soft delete policies
have generations. If generation is specified, bucket_or_name
must be a name (str).

andrewsg marked this conversation as resolved.
Show resolved Hide resolved
Returns:
google.cloud.storage.bucket.Bucket
The newly created bucket or the given one.
"""
if isinstance(bucket_or_name, Bucket):
if generation:
raise ValueError(
"The generation can only be specified if a "
"name is used to specify a bucket, not a Bucket object. "
"Create a new Bucket object with the correct generation "
"instead."
)
bucket = bucket_or_name
if bucket.client is None:
bucket._client = self
else:
bucket = Bucket(self, name=bucket_or_name)
bucket = Bucket(self, name=bucket_or_name, generation=generation)
return bucket

@create_trace_span(name="Storage.Client.getBucket")
Expand All @@ -819,6 +841,9 @@ def get_bucket(
if_metageneration_match=None,
if_metageneration_not_match=None,
retry=DEFAULT_RETRY,
*,
generation=None,
soft_deleted=None,
):
"""Retrieve a bucket via a GET request.

Expand All @@ -837,12 +862,12 @@ def get_bucket(
Can also be passed as a tuple (connect_timeout, read_timeout).
See :meth:`requests.Session.request` documentation for details.

if_metageneration_match (Optional[long]):
if_metageneration_match (Optional[int]):
Make the operation conditional on whether the
blob's current metageneration matches the given value.
bucket's current metageneration matches the given value.

if_metageneration_not_match (Optional[long]):
Make the operation conditional on whether the blob's
if_metageneration_not_match (Optional[int]):
Make the operation conditional on whether the bucket's
current metageneration does not match the given value.

retry (Optional[Union[google.api_core.retry.Retry, google.cloud.storage.retry.ConditionalRetryPolicy]]):
Expand All @@ -859,6 +884,19 @@ def get_bucket(
See the retry.py source code and docstrings in this package (google.cloud.storage.retry) for
information on retry types and how to configure them.

generation (Optional[int]):
The generation of the bucket. The generation can be used to
specify a specific soft-deleted version of the bucket, in
conjunction with the ``soft_deleted`` argument below. If
``soft_deleted`` is not True, the generation is unused.

soft_deleted (Optional[bool]):
If True, looks for a soft-deleted bucket. Will only return
the bucket metadata if the bucket exists and is in a
soft-deleted state. The bucket ``generation`` is required if
``soft_deleted`` is set to True.
See: https://cloud.google.com/storage/docs/soft-delete

Returns:
google.cloud.storage.bucket.Bucket
The bucket matching the name provided.
Expand All @@ -867,13 +905,14 @@ def get_bucket(
google.cloud.exceptions.NotFound
If the bucket is not found.
"""
bucket = self._bucket_arg_to_bucket(bucket_or_name)
bucket = self._bucket_arg_to_bucket(bucket_or_name, generation=generation)
bucket.reload(
client=self,
timeout=timeout,
if_metageneration_match=if_metageneration_match,
if_metageneration_not_match=if_metageneration_not_match,
retry=retry,
soft_deleted=soft_deleted,
)
return bucket

Expand Down Expand Up @@ -1386,6 +1425,8 @@ def list_buckets(
page_size=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
*,
soft_deleted=None,
):
"""Get all buckets in the project associated to the client.

Expand Down Expand Up @@ -1438,6 +1479,12 @@ def list_buckets(
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type soft_deleted: bool
:param soft_deleted:
(Optional) If true, only soft-deleted buckets will be listed as distinct results in order of increasing
generation number. This parameter can only be used successfully if the bucket has a soft delete policy.
See: https://cloud.google.com/storage/docs/soft-delete

:rtype: :class:`~google.api_core.page_iterator.Iterator`
:raises ValueError: if both ``project`` is ``None`` and the client's
project is also ``None``.
Expand Down Expand Up @@ -1469,6 +1516,9 @@ def list_buckets(
if fields is not None:
extra_params["fields"] = fields

if soft_deleted is not None:
extra_params["softDeleted"] = soft_deleted

return self._list_resource(
"/b",
_item_to_bucket,
Expand All @@ -1480,6 +1530,71 @@ def list_buckets(
retry=retry,
)

def restore_bucket(
self,
andrewsg marked this conversation as resolved.
Show resolved Hide resolved
bucket_name,
generation,
projection="noAcl",
if_metageneration_match=None,
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
andrewsg marked this conversation as resolved.
Show resolved Hide resolved
):
"""Restores a soft-deleted bucket.

:type bucket_name: str
:param bucket_name: The name of the bucket to be restored.

:type generation: int
:param generation: Selects the specific revision of the bucket.

:type projection: str
:param projection:
(Optional) Specifies the set of properties to return. If used, must
be 'full' or 'noAcl'. Defaults to 'noAcl'.

if_metageneration_match (Optional[int]):
Make the operation conditional on whether the
blob's current metageneration matches the given value.

if_metageneration_not_match (Optional[int]):
Make the operation conditional on whether the blob's
current metageneration does not match the given value.

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
for the server response. See: :ref:`configuring_timeouts`

:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC.

Users can configure non-default retry behavior. A ``None`` value will
disable retries. See [Configuring Retries](https://cloud.google.com/python/docs/reference/storage/latest/retry_timeout).

:rtype: :class:`google.cloud.storage.bucket.Bucket`
:returns: The restored Bucket.
"""
query_params = {"generation": generation, "projection": projection}

_add_generation_match_parameters(
query_params,
if_metageneration_match=if_metageneration_match,
if_metageneration_not_match=if_metageneration_not_match,
)

bucket = self.bucket(bucket_name)
api_response = self._post_resource(
f"{bucket.path}/restore",
None,
query_params=query_params,
timeout=timeout,
retry=retry,
)
bucket._set_properties(api_response)
return bucket

@create_trace_span(name="Storage.Client.createHmacKey")
def create_hmac_key(
self,
Expand Down
Loading