Thanks to visit codestin.com
Credit goes to github.com

Skip to content

implement S3 native RequestPayment, GetObject Part, Preconditions #8885

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

Merged
merged 4 commits into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions localstack/aws/api/s3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,14 @@ class ObjectLockConfigurationNotFoundError(ServiceException):
BucketName: Optional[BucketName]


class InvalidPartNumber(ServiceException):
code: str = "InvalidPartNumber"
sender_fault: bool = False
status_code: int = 416
PartNumberRequested: Optional[PartNumber]
ActualPartCount: Optional[PartNumber]


AbortDate = datetime


Expand Down Expand Up @@ -2233,6 +2241,7 @@ class HeadObjectOutput(TypedDict, total=False):
ObjectLockMode: Optional[ObjectLockMode]
ObjectLockRetainUntilDate: Optional[ObjectLockRetainUntilDate]
ObjectLockLegalHoldStatus: Optional[ObjectLockLegalHoldStatus]
StatusCode: Optional[GetObjectResponseStatusCode]


class HeadObjectRequest(ServiceRequest):
Expand Down
28 changes: 28 additions & 0 deletions localstack/aws/spec-patches.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@
"location": "statusCode"
}
},
{
"op": "add",
"path": "/shapes/HeadObjectOutput/members/StatusCode",
"value": {
"shape": "GetObjectResponseStatusCode",
"location": "statusCode"
}
},
{
"op": "add",
"path": "/shapes/NoSuchKey/members/Key",
Expand Down Expand Up @@ -1048,6 +1056,26 @@
"documentation": "<p>Object Lock configuration does not exist for this bucket</p>",
"exception": true
}
},
{
"op": "add",
"path": "/shapes/InvalidPartNumber",
"value": {
"type": "structure",
"members": {
"PartNumberRequested": {
"shape": "PartNumber"
},
"ActualPartCount": {
"shape": "PartNumber"
}
},
"error": {
"httpStatusCode": 416
},
"documentation": "<p>The requested partnumber is not satisfiable</p>",
"exception": true
}
}
]
}
28 changes: 4 additions & 24 deletions localstack/services/s3/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
capitalize_header_name_from_snake_case,
extract_bucket_key_version_id_from_copy_source,
get_bucket_from_moto,
get_failed_precondition_copy_source,
get_key_from_moto_bucket,
get_lifecycle_rule_from_object,
get_object_checksum_for_algorithm,
Expand Down Expand Up @@ -599,36 +600,15 @@ def copy_object(
)

# see https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
condition = None
source_object_last_modified = source_key_object.last_modified.replace(
tzinfo=ZoneInfo("GMT")
)
if (cs_if_match := request.get("CopySourceIfMatch")) and source_key_object.etag.strip(
'"'
) != cs_if_match.strip('"'):
condition = "x-amz-copy-source-If-Match"

elif (
cs_id_unmodified_since := request.get("CopySourceIfUnmodifiedSince")
) and source_object_last_modified > cs_id_unmodified_since:
condition = "x-amz-copy-source-If-Unmodified-Since"

elif (
cs_if_none_match := request.get("CopySourceIfNoneMatch")
) and source_key_object.etag.strip('"') == cs_if_none_match.strip('"'):
condition = "x-amz-copy-source-If-None-Match"

elif (
cs_id_modified_since := request.get("CopySourceIfModifiedSince")
) and source_object_last_modified < cs_id_modified_since < datetime.datetime.now(
tz=ZoneInfo("GMT")
if failed_condition := get_failed_precondition_copy_source(
request, source_object_last_modified, source_key_object.etag
):
condition = "x-amz-copy-source-If-Modified-Since"

if condition:
raise PreconditionFailed(
"At least one of the pre-conditions you specified did not hold",
Condition=condition,
Condition=failed_condition,
)

response: CopyObjectOutput = call_moto(context)
Expand Down
89 changes: 83 additions & 6 deletions localstack/services/s3/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
from localstack.aws.api.s3 import (
BucketName,
ChecksumAlgorithm,
CopyObjectRequest,
CopySource,
ETag,
GetObjectRequest,
HeadObjectRequest,
InvalidArgument,
InvalidRange,
InvalidTag,
Expand All @@ -34,6 +38,7 @@
ObjectSize,
ObjectVersionId,
Owner,
PreconditionFailed,
SSEKMSKeyId,
TaggingHeader,
TagSet,
Expand Down Expand Up @@ -79,6 +84,7 @@


RFC1123 = "%a, %d %b %Y %H:%M:%S GMT"
_gmt_zone_info = ZoneInfo("GMT")


def get_owner_for_account_id(account_id: str):
Expand Down Expand Up @@ -164,7 +170,7 @@ def digest(self) -> bytes:
return self.checksum.to_bytes(4, "big")


class ParsedRange(NamedTuple):
class ObjectRange(NamedTuple):
"""
NamedTuple representing a parsed Range header with the requested S3 object size
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
Expand All @@ -176,13 +182,13 @@ class ParsedRange(NamedTuple):
end: int # the end of the end


def parse_range_header(range_header: str, object_size: int) -> ParsedRange:
def parse_range_header(range_header: str, object_size: int) -> ObjectRange:
"""
Takes a Range header, and returns a dataclass containing the necessary information to return only a slice of an
S3 object
:param range_header: a Range header
:param object_size: the requested S3 object total size
:return: ParsedRange
:return: ObjectRange
"""
last = object_size - 1
_, rspec = range_header.split("=")
Expand All @@ -206,7 +212,7 @@ def parse_range_header(range_header: str, object_size: int) -> ParsedRange:
RangeRequested=range_header,
)

return ParsedRange(
return ObjectRange(
content_range=f"bytes {begin}-{end}/{object_size}",
content_length=end - begin + 1,
begin=begin,
Expand Down Expand Up @@ -585,7 +591,7 @@ def rfc_1123_datetime(src: datetime.datetime) -> str:


def str_to_rfc_1123_datetime(value: str) -> datetime.datetime:
return datetime.datetime.strptime(value, RFC1123).replace(tzinfo=ZoneInfo("GMT"))
return datetime.datetime.strptime(value, RFC1123).replace(tzinfo=_gmt_zone_info)


def iso_8601_datetime_without_milliseconds_s3(
Expand Down Expand Up @@ -776,10 +782,81 @@ def get_retention_from_now(days: int = None, years: int = None) -> datetime.date
"""
if not days and not years:
raise ValueError("Either 'days' or 'years' needs to be provided")
now = datetime.datetime.now(tz=ZoneInfo("GMT"))
now = datetime.datetime.now(tz=_gmt_zone_info)
if days:
retention = now + datetime.timedelta(days=days)
else:
retention = now.replace(year=now.year + years)

return retention


def get_failed_precondition_copy_source(
request: CopyObjectRequest, last_modified: datetime.datetime, etag: ETag
) -> Optional[str]:
"""
Validate if the source object LastModified and ETag matches a precondition, and if it does, return the failed
precondition
# see https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
:param request: the CopyObjectRequest
:param last_modified: source object LastModified
:param etag: source object ETag
:return str: the failed precondition to raise
"""
if (cs_if_match := request.get("CopySourceIfMatch")) and etag.strip('"') != cs_if_match.strip(
'"'
):
return "x-amz-copy-source-If-Match"

elif (
cs_if_unmodified_since := request.get("CopySourceIfUnmodifiedSince")
) and last_modified > cs_if_unmodified_since:
return "x-amz-copy-source-If-Unmodified-Since"

elif (cs_if_none_match := request.get("CopySourceIfNoneMatch")) and etag.strip(
'"'
) == cs_if_none_match.strip('"'):
return "x-amz-copy-source-If-None-Match"

elif (
cs_if_modified_since := request.get("CopySourceIfModifiedSince")
) and last_modified < cs_if_modified_since < datetime.datetime.now(tz=_gmt_zone_info):
return "x-amz-copy-source-If-Modified-Since"


def validate_failed_precondition(
request: GetObjectRequest | HeadObjectRequest, last_modified: datetime.datetime, etag: ETag
) -> None:
"""
Validate if the object LastModified and ETag matches a precondition, and if it does, return the failed
precondition
:param request: the GetObjectRequest or HeadObjectRequest
:param last_modified: S3 object LastModified
:param etag: S3 object ETag
:raises PreconditionFailed
:raises NotModified, 304 with an empty body
"""
precondition_failed = None
if (if_match := request.get("IfMatch")) and etag != if_match.strip('"'):
precondition_failed = "If-Match"

elif (
if_unmodified_since := request.get("IfUnmodifiedSince")
) and last_modified > if_unmodified_since:
precondition_failed = "If-Unmodified-Since"

if precondition_failed:
raise PreconditionFailed(
"At least one of the pre-conditions you specified did not hold",
Condition=precondition_failed,
)

if ((if_none_match := request.get("IfNoneMatch")) and etag == if_none_match.strip('"')) or (
(if_modified_since := request.get("IfModifiedSince"))
and last_modified < if_modified_since < datetime.datetime.now(tz=_gmt_zone_info)
):
raise CommonServiceException(
message="Not Modified",
code="NotModified",
status_code=304,
)
24 changes: 13 additions & 11 deletions localstack/services/s3/v3/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@

LOG = logging.getLogger(__name__)

gmt_zone_info = ZoneInfo("GMT")
_gmt_zone_info = ZoneInfo("GMT")


# note: not really a need to use a dataclass here, as it has a lot of fields, but only a few are set at creation
Expand Down Expand Up @@ -133,7 +133,8 @@ def __init__(
self.object_ownership = object_ownership
self.object_lock_enabled = object_lock_enabled_for_bucket
self.encryption_rule = DEFAULT_BUCKET_ENCRYPTION
self.creation_date = datetime.now(tz=gmt_zone_info)
self.creation_date = datetime.now(tz=_gmt_zone_info)
self.payer = Payer.BucketOwner
self.multiparts = {}
self.notification_configuration = {}
self.cors_rules = None
Expand Down Expand Up @@ -249,7 +250,7 @@ class S3Object:
website_redirect_location: Optional[WebsiteRedirectLocation]
acl: Optional[str] # TODO: we need to change something here, how it's done?
is_current: bool
parts: Optional[list[tuple[int, int]]]
parts: Optional[dict[int, tuple[int, int]]]
restore: Optional[Restore]

def __init__(
Expand Down Expand Up @@ -296,8 +297,8 @@ def __init__(
self.expiration = expiration
self.website_redirect_location = website_redirect_location
self.is_current = True
self.last_modified = datetime.now(tz=gmt_zone_info)
self.parts = []
self.last_modified = datetime.now(tz=_gmt_zone_info)
self.parts = {}
self.restore = None

def get_system_metadata_fields(self) -> dict:
Expand Down Expand Up @@ -349,7 +350,7 @@ def is_locked(self, bypass_governance: bool = False) -> bool:
return False

if self.lock_until:
return self.lock_until > datetime.now(tz=gmt_zone_info)
return self.lock_until > datetime.now(tz=_gmt_zone_info)

return False

Expand All @@ -364,7 +365,7 @@ class S3DeleteMarker:
def __init__(self, key: ObjectKey, version_id: ObjectVersionId):
self.key = key
self.version_id = version_id
self.last_modified = datetime.now(tz=gmt_zone_info)
self.last_modified = datetime.now(tz=_gmt_zone_info)
self.is_current = True

@staticmethod
Expand All @@ -390,7 +391,7 @@ def __init__(
checksum_algorithm: Optional[ChecksumAlgorithm] = None,
checksum_value: Optional[str] = None,
):
self.last_modified = datetime.now(tz=gmt_zone_info)
self.last_modified = datetime.now(tz=_gmt_zone_info)
self.part_number = part_number
self.size = size
self.etag = etag
Expand Down Expand Up @@ -430,7 +431,7 @@ def __init__(
tagging: Optional[dict[str, str]] = None,
):
self.id = token_urlsafe(96) # MultipartUploadId is 128 characters long
self.initiated = datetime.now(tz=gmt_zone_info)
self.initiated = datetime.now(tz=_gmt_zone_info)
self.parts = {}
self.initiator = initiator
self.tagging = tagging
Expand Down Expand Up @@ -488,8 +489,9 @@ def complete_multipart(self, parts: CompletedPartList):
)

object_etag.update(bytes.fromhex(s3_part.etag))
# TODO verify this, it seems wrong
self.object.parts.append((pos, s3_part.size))
# keep track of the parts size, as it can be queried afterward on the object as a Range
self.object.parts[part_number] = (pos, s3_part.size)
pos += s3_part.size

multipart_etag = f"{object_etag.hexdigest()}-{len(parts)}"
self.object.etag = multipart_etag
Expand Down
Loading