[S3] Support null version ids for buckets with versioning disabled

This commit is contained in:
Gilbert Gilb's 2019-03-08 22:01:27 +01:00
parent 5a20755565
commit dedb53371e
3 changed files with 132 additions and 44 deletions

View File

@ -10,6 +10,7 @@ import random
import string import string
import tempfile import tempfile
import sys import sys
import uuid
import six import six
@ -35,7 +36,7 @@ class FakeDeleteMarker(BaseModel):
self.key = key self.key = key
self.name = key.name self.name = key.name
self.last_modified = datetime.datetime.utcnow() self.last_modified = datetime.datetime.utcnow()
self._version_id = key.version_id + 1 self._version_id = str(uuid.uuid4())
@property @property
def last_modified_ISO8601(self): def last_modified_ISO8601(self):
@ -115,15 +116,16 @@ class FakeKey(BaseModel):
self.last_modified = datetime.datetime.utcnow() self.last_modified = datetime.datetime.utcnow()
self._etag = None # must recalculate etag self._etag = None # must recalculate etag
if self._is_versioned: if self._is_versioned:
self._version_id += 1 self._version_id = str(uuid.uuid4())
else: else:
self._is_versioned = 0 self._version_id = None
def restore(self, days): def restore(self, days):
self._expiry = datetime.datetime.utcnow() + datetime.timedelta(days) self._expiry = datetime.datetime.utcnow() + datetime.timedelta(days)
def increment_version(self): def refresh_version(self):
self._version_id += 1 self._version_id = str(uuid.uuid4())
self.last_modified = datetime.datetime.utcnow()
@property @property
def etag(self): def etag(self):
@ -716,17 +718,18 @@ class S3Backend(BaseBackend):
def get_bucket_latest_versions(self, bucket_name): def get_bucket_latest_versions(self, bucket_name):
versions = self.get_bucket_versions(bucket_name) versions = self.get_bucket_versions(bucket_name)
maximum_version_per_key = {} latest_modified_per_key = {}
latest_versions = {} latest_versions = {}
for version in versions: for version in versions:
name = version.name name = version.name
last_modified = version.last_modified
version_id = version.version_id version_id = version.version_id
maximum_version_per_key[name] = max( latest_modified_per_key[name] = max(
version_id, last_modified,
maximum_version_per_key.get(name, -1) latest_modified_per_key.get(name, datetime.datetime.min)
) )
if version_id == maximum_version_per_key[name]: if last_modified == latest_modified_per_key[name]:
latest_versions[name] = version_id latest_versions[name] = version_id
return latest_versions return latest_versions
@ -774,20 +777,19 @@ class S3Backend(BaseBackend):
bucket = self.get_bucket(bucket_name) bucket = self.get_bucket(bucket_name)
old_key = bucket.keys.get(key_name, None)
if old_key is not None and bucket.is_versioned:
new_version_id = old_key._version_id + 1
else:
new_version_id = 0
new_key = FakeKey( new_key = FakeKey(
name=key_name, name=key_name,
value=value, value=value,
storage=storage, storage=storage,
etag=etag, etag=etag,
is_versioned=bucket.is_versioned, is_versioned=bucket.is_versioned,
version_id=new_version_id) version_id=str(uuid.uuid4()) if bucket.is_versioned else None)
bucket.keys[key_name] = new_key
keys = [
key for key in bucket.keys.getlist(key_name, [])
if key.version_id != new_key.version_id
] + [new_key]
bucket.keys.setlist(key_name, keys)
return new_key return new_key
@ -977,7 +979,7 @@ class S3Backend(BaseBackend):
# By this point, the destination key must exist, or KeyError # By this point, the destination key must exist, or KeyError
if dest_bucket.is_versioned: if dest_bucket.is_versioned:
dest_bucket.keys[dest_key_name].increment_version() dest_bucket.keys[dest_key_name].refresh_version()
if storage is not None: if storage is not None:
key.set_storage_class(storage) key.set_storage_class(storage)
if acl is not None: if acl is not None:

View File

@ -1303,7 +1303,7 @@ S3_BUCKET_GET_VERSIONS = """<?xml version="1.0" encoding="UTF-8"?>
{% for key in key_list %} {% for key in key_list %}
<Version> <Version>
<Key>{{ key.name }}</Key> <Key>{{ key.name }}</Key>
<VersionId>{{ key.version_id }}</VersionId> <VersionId>{% if key.version_id is none %}null{% else %}{{ key.version_id }}{% endif %}</VersionId>
<IsLatest>{% if latest_versions[key.name] == key.version_id %}true{% else %}false{% endif %}</IsLatest> <IsLatest>{% if latest_versions[key.name] == key.version_id %}true{% else %}false{% endif %}</IsLatest>
<LastModified>{{ key.last_modified_ISO8601 }}</LastModified> <LastModified>{{ key.last_modified_ISO8601 }}</LastModified>
<ETag>{{ key.etag }}</ETag> <ETag>{{ key.etag }}</ETag>

View File

@ -444,7 +444,12 @@ def test_copy_key_with_version():
key.set_contents_from_string("some value") key.set_contents_from_string("some value")
key.set_contents_from_string("another value") key.set_contents_from_string("another value")
bucket.copy_key('new-key', 'foobar', 'the-key', src_version_id='0') key = [
key.version_id
for key in bucket.get_all_versions()
if not key.is_latest
][0]
bucket.copy_key('new-key', 'foobar', 'the-key', src_version_id=key)
bucket.get_key( bucket.get_key(
"the-key").get_contents_as_string().should.equal(b"another value") "the-key").get_contents_as_string().should.equal(b"another value")
@ -818,16 +823,19 @@ def test_key_version():
bucket = conn.create_bucket('foobar') bucket = conn.create_bucket('foobar')
bucket.configure_versioning(versioning=True) bucket.configure_versioning(versioning=True)
versions = []
key = Key(bucket) key = Key(bucket)
key.key = 'the-key' key.key = 'the-key'
key.version_id.should.be.none key.version_id.should.be.none
key.set_contents_from_string('some string') key.set_contents_from_string('some string')
key.version_id.should.equal('0') versions.append(key.version_id)
key.set_contents_from_string('some string') key.set_contents_from_string('some string')
key.version_id.should.equal('1') versions.append(key.version_id)
set(versions).should.have.length_of(2)
key = bucket.get_key('the-key') key = bucket.get_key('the-key')
key.version_id.should.equal('1') key.version_id.should.equal(versions[-1])
@mock_s3_deprecated @mock_s3_deprecated
@ -836,23 +844,25 @@ def test_list_versions():
bucket = conn.create_bucket('foobar') bucket = conn.create_bucket('foobar')
bucket.configure_versioning(versioning=True) bucket.configure_versioning(versioning=True)
key_versions = []
key = Key(bucket, 'the-key') key = Key(bucket, 'the-key')
key.version_id.should.be.none key.version_id.should.be.none
key.set_contents_from_string("Version 1") key.set_contents_from_string("Version 1")
key.version_id.should.equal('0') key_versions.append(key.version_id)
key.set_contents_from_string("Version 2") key.set_contents_from_string("Version 2")
key.version_id.should.equal('1') key_versions.append(key.version_id)
key_versions.should.have.length_of(2)
versions = list(bucket.list_versions()) versions = list(bucket.list_versions())
versions.should.have.length_of(2) versions.should.have.length_of(2)
versions[0].name.should.equal('the-key') versions[0].name.should.equal('the-key')
versions[0].version_id.should.equal('0') versions[0].version_id.should.equal(key_versions[0])
versions[0].get_contents_as_string().should.equal(b"Version 1") versions[0].get_contents_as_string().should.equal(b"Version 1")
versions[1].name.should.equal('the-key') versions[1].name.should.equal('the-key')
versions[1].version_id.should.equal('1') versions[1].version_id.should.equal(key_versions[1])
versions[1].get_contents_as_string().should.equal(b"Version 2") versions[1].get_contents_as_string().should.equal(b"Version 2")
key = Key(bucket, 'the2-key') key = Key(bucket, 'the2-key')
@ -1483,16 +1493,22 @@ def test_boto3_head_object_with_versioning():
s3.Object('blah', 'hello.txt').put(Body=old_content) s3.Object('blah', 'hello.txt').put(Body=old_content)
s3.Object('blah', 'hello.txt').put(Body=new_content) s3.Object('blah', 'hello.txt').put(Body=new_content)
versions = list(s3.Bucket('blah').object_versions.all())
latest = list(filter(lambda item: item.is_latest, versions))[0]
oldest = list(filter(lambda item: not item.is_latest, versions))[0]
head_object = s3.Object('blah', 'hello.txt').meta.client.head_object( head_object = s3.Object('blah', 'hello.txt').meta.client.head_object(
Bucket='blah', Key='hello.txt') Bucket='blah', Key='hello.txt')
head_object['VersionId'].should.equal('1') head_object['VersionId'].should.equal(latest.id)
head_object['ContentLength'].should.equal(len(new_content)) head_object['ContentLength'].should.equal(len(new_content))
old_head_object = s3.Object('blah', 'hello.txt').meta.client.head_object( old_head_object = s3.Object('blah', 'hello.txt').meta.client.head_object(
Bucket='blah', Key='hello.txt', VersionId='0') Bucket='blah', Key='hello.txt', VersionId=oldest.id)
old_head_object['VersionId'].should.equal('0') old_head_object['VersionId'].should.equal(oldest.id)
old_head_object['ContentLength'].should.equal(len(old_content)) old_head_object['ContentLength'].should.equal(len(old_content))
old_head_object['VersionId'].should_not.equal(head_object['VersionId'])
@mock_s3 @mock_s3
def test_boto3_copy_object_with_versioning(): def test_boto3_copy_object_with_versioning():
@ -1507,9 +1523,6 @@ def test_boto3_copy_object_with_versioning():
obj1_version = client.get_object(Bucket='blah', Key='test1')['VersionId'] obj1_version = client.get_object(Bucket='blah', Key='test1')['VersionId']
obj2_version = client.get_object(Bucket='blah', Key='test2')['VersionId'] obj2_version = client.get_object(Bucket='blah', Key='test2')['VersionId']
# Versions should be the same
obj1_version.should.equal(obj2_version)
client.copy_object(CopySource={'Bucket': 'blah', 'Key': 'test1'}, Bucket='blah', Key='test2') client.copy_object(CopySource={'Bucket': 'blah', 'Key': 'test1'}, Bucket='blah', Key='test2')
obj2_version_new = client.get_object(Bucket='blah', Key='test2')['VersionId'] obj2_version_new = client.get_object(Bucket='blah', Key='test2')['VersionId']
@ -2507,6 +2520,75 @@ def test_boto3_list_object_versions():
response['Body'].read().should.equal(items[-1]) response['Body'].read().should.equal(items[-1])
@mock_s3
def test_boto3_list_object_versions_with_versioning_disabled():
s3 = boto3.client('s3', region_name='us-east-1')
bucket_name = 'mybucket'
key = 'key-with-versions'
s3.create_bucket(Bucket=bucket_name)
items = (six.b('v1'), six.b('v2'))
for body in items:
s3.put_object(
Bucket=bucket_name,
Key=key,
Body=body
)
response = s3.list_object_versions(
Bucket=bucket_name
)
# One object version should be returned
len(response['Versions']).should.equal(1)
response['Versions'][0]['Key'].should.equal(key)
# The version id should be the string null
response['Versions'][0]['VersionId'].should.equal('null')
# Test latest object version is returned
response = s3.get_object(Bucket=bucket_name, Key=key)
response['Body'].read().should.equal(items[-1])
@mock_s3
def test_boto3_list_object_versions_with_versioning_enabled_late():
s3 = boto3.client('s3', region_name='us-east-1')
bucket_name = 'mybucket'
key = 'key-with-versions'
s3.create_bucket(Bucket=bucket_name)
items = (six.b('v1'), six.b('v2'))
s3.put_object(
Bucket=bucket_name,
Key=key,
Body=six.b('v1')
)
s3.put_bucket_versioning(
Bucket=bucket_name,
VersioningConfiguration={
'Status': 'Enabled'
}
)
s3.put_object(
Bucket=bucket_name,
Key=key,
Body=six.b('v2')
)
response = s3.list_object_versions(
Bucket=bucket_name
)
# Two object versions should be returned
len(response['Versions']).should.equal(2)
keys = set([item['Key'] for item in response['Versions']])
keys.should.equal({key})
# There should still be a null version id.
versionsId = set([item['VersionId'] for item in response['Versions']])
versionsId.should.contain('null')
# Test latest object version is returned
response = s3.get_object(Bucket=bucket_name, Key=key)
response['Body'].read().should.equal(items[-1])
@mock_s3 @mock_s3
def test_boto3_bad_prefix_list_object_versions(): def test_boto3_bad_prefix_list_object_versions():
s3 = boto3.client('s3', region_name='us-east-1') s3 = boto3.client('s3', region_name='us-east-1')
@ -2563,18 +2645,25 @@ def test_boto3_delete_markers():
Bucket=bucket_name, Bucket=bucket_name,
Key=key Key=key
) )
e.response['Error']['Code'].should.equal('404') e.exception.response['Error']['Code'].should.equal('NoSuchKey')
response = s3.list_object_versions(
Bucket=bucket_name
)
response['Versions'].should.have.length_of(2)
response['DeleteMarkers'].should.have.length_of(1)
s3.delete_object( s3.delete_object(
Bucket=bucket_name, Bucket=bucket_name,
Key=key, Key=key,
VersionId='2' VersionId=response['DeleteMarkers'][0]['VersionId']
) )
response = s3.get_object( response = s3.get_object(
Bucket=bucket_name, Bucket=bucket_name,
Key=key Key=key
) )
response['Body'].read().should.equal(items[-1]) response['Body'].read().should.equal(items[-1])
response = s3.list_object_versions( response = s3.list_object_versions(
Bucket=bucket_name Bucket=bucket_name
) )
@ -2583,10 +2672,8 @@ def test_boto3_delete_markers():
# We've asserted there is only 2 records so one is newest, one is oldest # We've asserted there is only 2 records so one is newest, one is oldest
latest = list(filter(lambda item: item['IsLatest'], response['Versions']))[0] latest = list(filter(lambda item: item['IsLatest'], response['Versions']))[0]
oldest = list(filter(lambda item: not item['IsLatest'], response['Versions']))[0] oldest = list(filter(lambda item: not item['IsLatest'], response['Versions']))[0]
# Double check ordering of version ID's # Double check ordering of version ID's
latest['VersionId'].should.equal('1') latest['VersionId'].should_not.equal(oldest['VersionId'])
oldest['VersionId'].should.equal('0')
# Double check the name is still unicode # Double check the name is still unicode
latest['Key'].should.equal('key-with-versions-and-unicode-ó') latest['Key'].should.equal('key-with-versions-and-unicode-ó')
@ -2631,12 +2718,12 @@ def test_boto3_multiple_delete_markers():
s3.delete_object( s3.delete_object(
Bucket=bucket_name, Bucket=bucket_name,
Key=key, Key=key,
VersionId='2' VersionId=response['DeleteMarkers'][0]['VersionId']
) )
s3.delete_object( s3.delete_object(
Bucket=bucket_name, Bucket=bucket_name,
Key=key, Key=key,
VersionId='3' VersionId=response['DeleteMarkers'][1]['VersionId']
) )
response = s3.get_object( response = s3.get_object(
@ -2652,8 +2739,7 @@ def test_boto3_multiple_delete_markers():
oldest = list(filter(lambda item: not item['IsLatest'], response['Versions']))[0] oldest = list(filter(lambda item: not item['IsLatest'], response['Versions']))[0]
# Double check ordering of version ID's # Double check ordering of version ID's
latest['VersionId'].should.equal('1') latest['VersionId'].should_not.equal(oldest['VersionId'])
oldest['VersionId'].should.equal('0')
# Double check the name is still unicode # Double check the name is still unicode
latest['Key'].should.equal('key-with-versions-and-unicode-ó') latest['Key'].should.equal('key-with-versions-and-unicode-ó')