Merge "WIP mpu: introduce s3-compat mode for object-versioning" into feature/mpu

This commit is contained in:
Zuul
2025-09-02 12:21:10 +00:00
committed by Gerrit Code Review
10 changed files with 779 additions and 73 deletions

View File

@@ -21,7 +21,8 @@ from urllib.parse import quote
from swift.common import swob
from swift.common.http import HTTP_OK
from swift.common.middleware.versioned_writes.object_versioning import \
DELETE_MARKER_CONTENT_TYPE, validate_version
DELETE_MARKER_CONTENT_TYPE, validate_version, \
SYSMETA_S3_COMPATIBLE_VERSIONS
from swift.common.utils import json, public, config_true_value, cap_length
from swift.common.registry import get_swift_info
@@ -388,6 +389,10 @@ class BucketController(Controller):
# s3api cannot support multiple regions currently.
raise InvalidLocationConstraint()
# default to s3 compat
# TODO: need backwards compatibility with containers already using the
# swift mode
req.headers[SYSMETA_S3_COMPATIBLE_VERSIONS] = 'true'
resp = req.get_response(self.app)
resp.status = HTTP_OK

View File

@@ -334,7 +334,7 @@ class ObjectController(Controller):
pass # drain the bulk-deleter response
resp.status = HTTP_NO_CONTENT
resp.body = b''
if resp.sw_headers.get('X-Object-Current-Version-Id') == 'null':
if resp.sw_headers.get('X-Object-Current-Version-Id') == 'none':
new_resp = self._restore_on_delete(req)
if new_resp:
resp = new_resp

View File

@@ -77,6 +77,7 @@ class VersioningController(Controller):
# Set up versioning
# NB: object_versioning responsible for ensuring its container exists
req.headers['X-Versions-Enabled'] = str(status == 'Enabled').lower()
req.headers['X-S3-Compatible-Versions'] = 'true'
req.get_response(self.app, 'POST')
return HTTPOk()

View File

@@ -62,7 +62,7 @@ from swift.common.middleware.s3api.s3response import AccessDenied, \
AuthorizationQueryParametersError, ServiceUnavailable, BrokenMPU, \
XAmzContentSHA256Mismatch, IncompleteBody, InvalidChunkSizeError, \
InvalidPartNumber, InvalidPartArgument, MalformedTrailerError, \
NoSuchUpload
NoSuchUpload, NoSuchVersion
from swift.common.middleware.s3api.exception import NotS3Request, \
S3InputError, S3InputSizeError, S3InputIncomplete, \
S3InputChunkSignatureMismatch, S3InputChunkTooSmall, \
@@ -2073,6 +2073,9 @@ class S3Request(swob.Request):
def not_found_handler():
if is_success(get_container_info(
env, app, swift_source='S3').get('status')):
version_id = self.params.get('versionId')
if version_id:
return NoSuchVersion(obj, version_id)
return NoSuchKey(obj)
return NoSuchBucket(container)

View File

@@ -177,6 +177,10 @@ DELETE_MARKER_CONTENT_TYPE = 'application/x-deleted;swift_versions_deleted=1'
CLIENT_VERSIONS_ENABLED = 'x-versions-enabled'
SYSMETA_VERSIONS_ENABLED = \
get_sys_meta_prefix('container') + 'versions-enabled'
S3_COMPATIBLE_VERSIONS = 's3-compatible-versions'
CLIENT_S3_COMPATIBLE_VERSIONS = 'x-' + S3_COMPATIBLE_VERSIONS
SYSMETA_S3_COMPATIBLE_VERSIONS = \
get_sys_meta_prefix('container') + S3_COMPATIBLE_VERSIONS
SYSMETA_VERSIONS_CONT = get_sys_meta_prefix('container') + 'versions-container'
SYSMETA_PARENT_CONT = get_sys_meta_prefix('container') + 'parent-container'
SYSMETA_VERSIONS_SYMLINK = get_sys_meta_prefix('object') + 'versions-symlink'
@@ -227,8 +231,9 @@ def build_versions_object_name(object_name, version):
# Drop any offset from ts. Timestamp offsets are never exposed to
# clients, so Timestamp.normal is sufficient to define a version as
# perceived by clients.
inv = ~Timestamp(Timestamp(version).normal)
return get_reserved_name(object_name, inv.internal)
if version != 'null':
version = (~Timestamp(Timestamp(version).normal)).internal
return get_reserved_name(object_name, version)
def build_versions_object_marker(object_name):
@@ -251,7 +256,10 @@ def parse_versions_object_name(versioned_name):
"""
try:
name, suffix = split_reserved_name(versioned_name)
version = (~Timestamp(suffix)).internal
if suffix == 'null':
version = suffix
else:
version = (~Timestamp(suffix)).internal
except ValueError:
return versioned_name, None
return name, version
@@ -318,7 +326,7 @@ class ObjectVersioningContext(WSGIContext):
class ObjectContext(ObjectVersioningContext):
def __init__(self, wsgi_app, logger, api_version, account,
container, obj, versions_cont, is_enabled):
container, obj, versions_cont, is_enabled, s3_compat):
"""
Note that account, container, obj should be unquoted by caller
if the url path is under url encoding (e.g. %FF)
@@ -339,6 +347,7 @@ class ObjectContext(ObjectVersioningContext):
self.obj = obj
self.versions_cont = versions_cont
self.is_enabled = is_enabled
self.s3_compat = s3_compat
def get_version(self, req):
"""
@@ -404,6 +413,7 @@ class ObjectContext(ObjectVersioningContext):
put_resp = put_req.get_response(self.app)
drain_and_close(put_resp)
# the PUT should have already drained source_resp
# TODO: why are we trying to close an *iter*?
close_if_possible(source_resp.app_iter)
return put_resp
@@ -547,8 +557,12 @@ class ObjectContext(ObjectVersioningContext):
# if there's an existing object, then copy it to
# X-Versions-Location
version = self.get_null_version(get_resp)
get_resp.headers.pop('x-timestamp', None)
if self.s3_compat:
version = 'null'
else:
version = self.get_null_version(get_resp)
get_resp.headers.pop('x-timestamp', None)
vers_obj_name = build_versions_object_name(self.obj, version)
put_path_info = "/%s/%s/%s/%s" % (
self.api_version, self.account, self.versions_cont, vers_obj_name)
@@ -574,16 +588,20 @@ class ObjectContext(ObjectVersioningContext):
:param req: original request.
"""
# handle object request for a disabled versioned container.
if not self.is_enabled:
if self.is_enabled:
# attempt to copy current object to versions container
self._copy_current(req)
# then put to versions container
req.ensure_x_timestamp()
version = self.get_version(req)
elif self.s3_compat:
# put to versions container while versioning is suspended
version = 'null'
else:
# put to user container while versioning is suspended
return req.get_response(self.app)
# attempt to copy current object to versions container
self._copy_current(req)
# write client's put directly to versioned container
req.ensure_x_timestamp()
version = self.get_version(req)
put_resp, put_vers_obj_name, put_bytes, put_content_type = \
self._put_versioned_obj_from_client(req, version)
@@ -602,13 +620,19 @@ class ObjectContext(ObjectVersioningContext):
:param req: original request.
"""
# handle object request for a disabled versioned container.
if not self.is_enabled:
if self.is_enabled:
# attempt to copy current object to versions container
self._copy_current(req)
# then put to versions container
req.ensure_x_timestamp()
version = self.get_version(req)
elif self.s3_compat:
# put to versions container while versioning is suspended
version = 'null'
else:
# put to user container while versioning is suspended
return req.get_response(self.app)
self._copy_current(req)
req.ensure_x_timestamp()
version = self.get_version(req)
marker_name = build_versions_object_name(self.obj, version)
marker_path = "/%s/%s/%s/%s" % (
self.api_version, self.account, self.versions_cont, marker_name)
@@ -681,7 +705,6 @@ class ObjectContext(ObjectVersioningContext):
req.environ, path=wsgi_quote(req.path_info) + '?symlink=get',
method='HEAD', headers=obj_head_headers, swift_source='OV')
hresp = head_req.get_response(self.app)
head_is_tombstone = False
symlink_target = None
if hresp.status_int == HTTP_NOT_FOUND:
head_is_tombstone = True
@@ -702,8 +725,9 @@ class ObjectContext(ObjectVersioningContext):
:param req: original request.
:param version: version to delete.
"""
if version == 'null':
# let the request go directly through to the is_latest link
if version == 'null' and not (self.s3_compat and self.versions_cont):
# let the request go directly through to the is_latest link unless
# for an s3-compat versioned container
return req.get_response(self.app)
auth_token_header = {'X-Auth-Token': req.headers.get('X-Auth-Token')}
head_is_tombstone, symlink_target = self._check_head(
@@ -711,8 +735,23 @@ class ObjectContext(ObjectVersioningContext):
versions_obj = build_versions_object_name(self.obj, version)
req_obj_path = '%s/%s' % (self.versions_cont, versions_obj)
if head_is_tombstone or not symlink_target or (
wsgi_unquote(symlink_target) != wsgi_unquote(req_obj_path)):
if head_is_tombstone:
version_is_latest = False
resp_version_id = None
elif symlink_target:
symlink_target = wsgi_unquote(symlink_target)
if symlink_target == wsgi_unquote(req_obj_path):
version_is_latest = True
resp_version_id = None
else:
version_is_latest = False
_, vers_obj_name = symlink_target.split('/', 1)
resp_version_id = parse_versions_object_name(vers_obj_name)[1]
else:
# de-facto null version (never been copied to versions container)
version_is_latest = version == 'null'
resp_version_id = None
if not version_is_latest:
# If there's no current version (i.e., tombstone or unversioned
# object) or if current version links to another version, then
# just delete the version requested to be deleted
@@ -720,11 +759,6 @@ class ObjectContext(ObjectVersioningContext):
self.api_version, self.account, self.versions_cont,
versions_obj)
req.headers['X-Backend-Allow-Reserved-Names'] = 'true'
if head_is_tombstone or not symlink_target:
resp_version_id = 'null'
else:
_, vers_obj_name = wsgi_unquote(symlink_target).split('/', 1)
resp_version_id = parse_versions_object_name(vers_obj_name)[1]
else:
# if version-id is the latest version, delete the link too
# First, kill the link...
@@ -738,10 +772,19 @@ class ObjectContext(ObjectVersioningContext):
self.api_version, self.account, self.versions_cont,
versions_obj)
req.headers['X-Backend-Allow-Reserved-Names'] = 'true'
resp_version_id = 'null'
resp = req.get_response(self.app)
resp.headers['X-Object-Version-Id'] = version
resp.headers['X-Object-Current-Version-Id'] = resp_version_id
if self.s3_compat and (version_is_latest or not resp_version_id):
# The only in-tree use for this header is in the s3api object
# response handler, which performs a restore-on-delete if the
# header is 'none'.
resp.headers['X-Object-Current-Version-Id'] = 'none'
elif not resp_version_id:
# For backwards compatibility with any out-of-tree use case, 'null'
# is returned in the non-s3-compat scenario.
resp.headers['X-Object-Current-Version-Id'] = 'null'
else:
resp.headers['X-Object-Current-Version-Id'] = resp_version_id
return resp
def handle_put_with_version_id(self, req, version):
@@ -752,6 +795,7 @@ class ObjectContext(ObjectVersioningContext):
:param req: original request.
:param version: version to make the latest.
"""
# this is used by s3api restore-on-delete
if req.is_chunked:
has_body = (req.body_file.read(1) != b'')
elif req.content_length is None:
@@ -829,6 +873,10 @@ class ObjectContext(ObjectVersioningContext):
if req.method == 'HEAD':
drain_and_close(resp)
return resp
elif self.s3_compat and self.versions_cont:
# allow the request to be redirected to the versions container
close_if_possible(resp.app_iter)
return None
elif is_version_link:
# Have a latest version, but it's got a real version-id.
# Since the user specifically asked for null, return 404
@@ -1123,11 +1171,14 @@ class ContainerContext(ObjectVersioningContext):
versions_cont = container_info.get(
'sysmeta', {}).get('versions-container')
is_enabled = config_true_value(
req.headers[CLIENT_VERSIONS_ENABLED])
is_enabled = config_true_value(req.headers[CLIENT_VERSIONS_ENABLED])
req.headers[SYSMETA_VERSIONS_ENABLED] = is_enabled
if CLIENT_S3_COMPATIBLE_VERSIONS in req.headers:
s3_compat = config_true_value(
req.headers[CLIENT_S3_COMPATIBLE_VERSIONS])
if not s3_compat:
raise HTTPBadRequest('Cannot disable s3 compatible versions')
req.headers[SYSMETA_S3_COMPATIBLE_VERSIONS] = s3_compat
# TODO: a POST request to a primary container that doesn't exist
# will fail, so we will create and delete the versions container
# for no reason
@@ -1207,6 +1258,20 @@ class ContainerContext(ObjectVersioningContext):
self._response_exc_info)
return app_resp
def _insert_null_item(self, listing, null_item):
# TODO: this is ok for tests but obvs won't work for paged or marker
# listing. We'll need to do a prefix listing for the null and then
# insert it in each page of a listing.
i = len(listing)
for item in reversed(listing):
if item['name'] != null_item['name']:
break
if item['last_modified'] > null_item['last_modified']:
break
i -= 1
listing.insert(i, null_item)
return listing
def _list_versions(self, req, start_response, location, primary_listing):
# Only supports JSON listings
req.environ['swift.format_listing'] = False
@@ -1314,9 +1379,11 @@ class ContainerContext(ObjectVersioningContext):
try:
listing = json.loads(versions_resp.body)
except ValueError:
# TODO: fix, body unresolved here
app_resp = [body]
else:
versions_listing = []
null_item = None
for item in listing:
if 'name' not in item:
# remove reserved chars from subdir
@@ -1346,7 +1413,16 @@ class ContainerContext(ObjectVersioningContext):
item['name'] = name
item['version_id'] = version
versions_listing.append(item)
if null_item and null_item['name'] != name:
self._insert_null_item(versions_listing, null_item)
null_item = None
if version == 'null':
null_item = item
else:
versions_listing.append(item)
if null_item:
self._insert_null_item(versions_listing, null_item)
subdir_listing = [{'subdir': s} for s in subdir_set]
broken_listing = []
@@ -1527,15 +1603,16 @@ class ObjectVersioningMiddleware(object):
container_info = get_container_info(
req.environ, self.app, swift_source='OV')
versions_cont = container_info.get(
'sysmeta', {}).get('versions-container', '')
sysmeta = container_info.get('sysmeta', {})
versions_cont = sysmeta.get('versions-container', '')
if versions_cont:
versions_cont = wsgi_unquote(str_to_wsgi(
versions_cont)).split('/')[0]
is_enabled = is_versioning_enabled(container_info)
s3_compat = config_true_value(sysmeta.get(S3_COMPATIBLE_VERSIONS))
object_ctx = ObjectContext(
self.app, self.logger, api_version, account, container, obj,
versions_cont, is_enabled)
versions_cont, is_enabled, s3_compat)
return object_ctx.handle_request(req)
def __call__(self, env, start_response):

View File

@@ -138,6 +138,7 @@ class TestObjectVersioningBase(Base):
# since it gets disabled in tearDown
self.env.container.update_metadata(
hdrs={self.env.versions_header_key: 'True'})
self.maxDiff = None
def _tear_down_files(self, container):
try:
@@ -1121,6 +1122,8 @@ class TestObjectVersioning(TestObjectVersioningBase):
obj.info(parms={'version-id': dm_version_id})
resp_headers = {
h.lower(): v for h, v in cm.exception.headers}
self.assertIn('x-object-version-id', resp_headers,
(cm.exception.status, cm.exception.details))
self.assertEqual(dm_version_id,
resp_headers['x-object-version-id'])
self.assertEqual(DELETE_MARKER_CONTENT_TYPE,

View File

@@ -15,6 +15,7 @@
import time
from collections import defaultdict
from unittest import mock
from botocore.exceptions import ClientError
import io
@@ -93,6 +94,21 @@ class TestObjectVersioning(BaseS3TestCase):
self.clear_bucket(self.client, self.bucket_name)
super(TestObjectVersioning, self).tearDown()
def assert_no_such_key(self, bucket_name, obj_name):
with self.assertRaises(ClientError) as caught:
self.client.get_object(Bucket=bucket_name, Key=obj_name)
expected_err = 'An error occurred (NoSuchKey) when calling the ' \
'GetObject operation: The specified key does not exist.'
self.assertEqual(expected_err, str(caught.exception))
def assert_no_such_version(self, bucket_name, obj_name, version_id):
with self.assertRaises(ClientError) as caught:
self.client.get_object(Bucket=bucket_name, Key=obj_name,
VersionId=version_id)
expected_err = 'An error occurred (NoSuchVersion) when calling the ' \
'GetObject operation: The specified version does not exist.'
self.assertEqual(expected_err, str(caught.exception))
def test_setup(self):
bucket_name = self.create_name('new-bucket')
resp = self.client.create_bucket(Bucket=bucket_name)
@@ -152,6 +168,296 @@ class TestObjectVersioning(BaseS3TestCase):
self.assertEqual('Suspended', resp['Status'])
retry(check_status)
def test_no_overwrite_while_versioning_enabled(self):
# verify that the null version prior to versioning being enabled will
# *not* become a version if it is not overwritten while versioning is
# enabled.
obj_name = self.create_name('unversioned-obj')
self.client.upload_fileobj(io.BytesIO(b'some-data'),
self.bucket_name, obj_name)
self.enable_versioning()
resp = self.get_versioning_status()
self.assertEqual('Enabled', resp.get('Status'), resp)
self.disable_versioning()
resp = self.get_versioning_status()
self.assertEqual('Suspended', resp.get('Status'), resp)
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
self.assertEqual(1, len(objs))
self.assertEqual(obj_name, objs[0]['Key'])
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
resp = self.client.list_object_versions(Bucket=self.bucket_name)
self.assertNotIn('Versions', resp)
markers = resp.get('DeleteMarkers', [])
self.assertEqual(1, len(markers))
self.assertEqual(obj_name, markers[0]['Key'])
def test_null_versions_replaced(self):
# verify that there is only ever one null version retained
obj_name = self.create_name('versioned-obj')
# put null version
self.client.upload_fileobj(io.BytesIO(b'some-data'),
self.bucket_name, obj_name)
# there's a 'null' version even before versioning has been enabled
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(['null'], version_ids)
self.assertFalse(marker_ids)
self.enable_versioning()
# put version
self.client.upload_fileobj(io.BytesIO(b'some-data'),
self.bucket_name, obj_name)
self.disable_versioning()
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(2, len(version_ids))
vers0 = version_ids[0]
self.assertEqual([vers0, 'null'], version_ids, version_ids)
self.assertFalse(marker_ids)
# put null version
self.client.upload_fileobj(io.BytesIO(b'some-data'),
self.bucket_name, obj_name)
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(['null', vers0], version_ids)
self.assertFalse(marker_ids)
# delete
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
# null version gone...
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertEqual([vers0], version_ids)
self.assertEqual(['null'], marker_ids)
# put null version
self.client.upload_fileobj(io.BytesIO(b'some-data'),
self.bucket_name, obj_name)
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertFalse(marker_ids)
self.assertEqual(['null', vers0], version_ids)
self.enable_versioning()
# put version
self.client.upload_fileobj(io.BytesIO(b'some-data'),
self.bucket_name, obj_name)
self.disable_versioning()
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertFalse(marker_ids)
self.assertEqual(3, len(version_ids), version_ids)
vers1 = version_ids[0]
self.assertEqual([vers1, 'null', vers0], version_ids)
# put null version
self.client.upload_fileobj(io.BytesIO(b'some-data'),
self.bucket_name, obj_name)
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertFalse(marker_ids)
self.assertEqual(['null', vers1, vers0], version_ids)
def test_null_version_listing(self):
# verify that null version is positioned in listing according to its
# created time
obj_name = self.create_name('versioned-obj')
self.enable_versioning()
# put version
self.client.upload_fileobj(io.BytesIO(b'retained-version'),
self.bucket_name, obj_name)
version_ids_0, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(1, len(version_ids_0), version_ids_0)
self.assertFalse(marker_ids)
self.disable_versioning()
# put null version
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
version_ids_1, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(2, len(version_ids_1), version_ids_1)
self.assertFalse(marker_ids)
# null version is first in listing
self.assertEqual(version_ids_0, version_ids_1[1:])
self.enable_versioning()
# put version
self.client.upload_fileobj(io.BytesIO(b'retained-version'),
self.bucket_name, obj_name)
version_ids_2, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(3, len(version_ids_2), version_ids_2)
self.assertFalse(marker_ids)
# null version is middle of listing
self.assertEqual(version_ids_1, version_ids_2[1:])
self.disable_versioning()
# put null version
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
version_ids_3, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(3, len(version_ids_3), version_ids_3)
self.assertFalse(marker_ids)
# null version is first in listing
self.assertEqual([version_ids_2[0], version_ids_2[2]],
version_ids_3[1:])
def _do_test_null_version_is_latest_delete(self, obj_name):
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertEqual(['null'], version_ids)
self.assertFalse(marker_ids)
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name,
VersionId='null')
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
# delete the null version
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId='null')
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
version_ids, marker_ids = self.get_version_ids(obj_name)
self.assertFalse(version_ids)
self.assertFalse(marker_ids)
self.assert_no_such_version(self.bucket_name, obj_name, 'null')
def test_null_version_is_latest_delete_before_versioning_enabled(self):
obj_name = self.create_name('versioned-obj')
# put null version
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
self._do_test_null_version_is_latest_delete(obj_name)
def test_null_version_is_latest_delete_while_versioning_enabled(self):
obj_name = self.create_name('versioned-obj')
# put null version
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
self.enable_versioning()
self._do_test_null_version_is_latest_delete(obj_name)
def test_null_version_is_latest_delete_while_versioning_suspended(self):
obj_name = self.create_name('versioned-obj')
# put null version
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
self.enable_versioning()
self.disable_versioning()
self._do_test_null_version_is_latest_delete(obj_name)
def test_null_version_delete_while_versioning_enabled(self):
obj_name = self.create_name('versioned-obj')
# put null version
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
self.enable_versioning()
# get the null version
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name,
VersionId='null')
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
# put version
self.client.upload_fileobj(io.BytesIO(b'retained-version'),
self.bucket_name, obj_name)
version_ids_0, marker_ids_0 = self.get_version_ids(obj_name)
self.assertEqual(2, len(version_ids_0), version_ids_0)
self.assertFalse(marker_ids_0)
vers1, null_vers1 = version_ids_0
# get the null version
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name,
VersionId='null')
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
# delete the null version
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=null_vers1)
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
version_ids_1, marker_ids_1 = self.get_version_ids(obj_name)
self.assertEqual(version_ids_0[:1], version_ids_1)
# check the latest version is intact
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
exp_etag = md5(b'retained-version', usedforsecurity=False).hexdigest()
self.assertEqual('"%s"' % exp_etag, resp['ETag'])
def test_null_version_delete_while_versioning_suspended(self):
obj_name = self.create_name('versioned-obj')
# put null version
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
self.enable_versioning()
# put version
self.client.upload_fileobj(io.BytesIO(b'retained-version'),
self.bucket_name, obj_name)
version_ids_0, marker_ids_0 = self.get_version_ids(obj_name)
self.assertEqual(2, len(version_ids_0), version_ids_0)
self.assertFalse(marker_ids_0)
vers1, null_vers1 = version_ids_0
self.disable_versioning()
# delete the null version
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=null_vers1)
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
version_ids_1, marker_ids_1 = self.get_version_ids(obj_name)
self.assertEqual(version_ids_0[:1], version_ids_1)
# check the latest version is intact
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
exp_etag = md5(b'retained-version', usedforsecurity=False).hexdigest()
self.assertEqual('"%s"' % exp_etag, resp['ETag'])
def test_null_version_get(self):
# verify that null version is always valid
obj_name = self.create_name('versioned-obj')
# before object exists...
self.assert_no_such_version(self.bucket_name, obj_name, 'null')
# before versioning is enabled...
self.client.upload_fileobj(io.BytesIO(b'null-version'),
self.bucket_name, obj_name)
self.assertEqual((['null'], []), self.get_version_ids(obj_name))
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name,
VersionId='null')
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
# while versioning is enabled...
self.enable_versioning()
self.assertEqual((['null'], []), self.get_version_ids(obj_name))
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name,
VersionId='null')
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
# while versioning is suspended...
self.disable_versioning()
self.assertEqual((['null'], []), self.get_version_ids(obj_name))
resp = self.client.get_object(Bucket=self.bucket_name, Key=obj_name,
VersionId='null')
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
def test_upload_fileobj_versioned(self):
retry(self.enable_versioning)
obj_data = self.create_name('some-data').encode('ascii')
@@ -224,16 +530,7 @@ class TestObjectVersioning(BaseS3TestCase):
'StorageClass': 'STANDARD',
}], objs)
def test_delete_versioned_objects(self):
retry(self.enable_versioning)
etags = []
obj_name = self.create_name('versioned-obj')
for i in range(3):
obj_data = self.create_name('some-data-%s' % i).encode('ascii')
etags.insert(0, md5(obj_data, usedforsecurity=False).hexdigest())
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
def _do_test_delete_versioned_objects(self, obj_name, obj_data, etags):
# only one object appears in the listing
resp = self.client.list_objects_v2(Bucket=self.bucket_name)
objs = resp.get('Contents', [])
@@ -246,6 +543,12 @@ class TestObjectVersioning(BaseS3TestCase):
'StorageClass': 'STANDARD',
}], objs)
# ...and that's the object that a plain GET will return
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
self.assertEqual('"%s"' % etags[0], resp['ETag'])
# but everything is layed out in the object versions listing
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
@@ -272,6 +575,7 @@ class TestObjectVersioning(BaseS3TestCase):
'Size': len(obj_data),
'StorageClass': 'STANDARD',
}], objs)
self.assertFalse(resp.get('DeleteMarkers'))
# we can delete a specific version
resp = self.client.delete_object(Bucket=self.bucket_name,
@@ -283,20 +587,22 @@ class TestObjectVersioning(BaseS3TestCase):
objs = resp.get('Versions', [])
for obj in objs:
self._sanitize_obj_listing(obj)
obj.pop('VersionId')
self.assertEqual([{
'ETag': '"%s"' % etags[0],
'IsLatest': True,
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
'VersionId': versions[0],
}, {
'ETag': '"%s"' % etags[2],
'IsLatest': False,
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
'VersionId': versions[2],
}], objs)
self.assertFalse(resp.get('DeleteMarkers'))
# ... but the current listing is unaffected
resp = self.client.list_objects_v2(Bucket=self.bucket_name)
@@ -310,25 +616,33 @@ class TestObjectVersioning(BaseS3TestCase):
'StorageClass': 'STANDARD',
}], objs)
# ...and that's still the object that a plain GET will return
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
self.assertEqual('"%s"' % etags[0], resp['ETag'])
# OTOH, if you delete specifically the latest version
# we can delete a specific version
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=versions[0])
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
# the versions listing has a new IsLatest
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
for obj in objs:
self._sanitize_obj_listing(obj)
obj.pop('VersionId')
self.assertEqual([{
'ETag': '"%s"' % etags[2],
'IsLatest': True,
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
'VersionId': versions[2],
}], objs)
self.assertFalse(resp.get('DeleteMarkers'))
# and the stack pops
resp = self.client.list_objects_v2(Bucket=self.bucket_name)
@@ -342,6 +656,305 @@ class TestObjectVersioning(BaseS3TestCase):
'StorageClass': 'STANDARD',
}], objs)
# ...and the restored version is now what a plain GET will return
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
self.assertEqual('"%s"' % etags[2], resp['ETag'])
def test_delete_versioned_objects_while_versioning_enabled(self):
# verify DELETE?versionId=xxx and restore-on-delete
retry(self.enable_versioning)
etags = []
obj_name = self.create_name('versioned-obj')
for i in range(3):
obj_data = self.create_name('some-data-%s' % i).encode('ascii')
etags.insert(0, md5(obj_data, usedforsecurity=False).hexdigest())
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
self._do_test_delete_versioned_objects(obj_name, obj_data, etags)
def test_delete_versioned_objects_while_versioning_suspended(self):
# verify DELETE?versionId=xxx and restore-on-delete
retry(self.enable_versioning)
etags = []
obj_name = self.create_name('versioned-obj')
for i in range(3):
obj_data = self.create_name('some-data-%s' % i).encode('ascii')
etags.insert(0, md5(obj_data, usedforsecurity=False).hexdigest())
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
retry(self.disable_versioning)
self._do_test_delete_versioned_objects(obj_name, obj_data, etags)
def _test_delete_version_previous_restored(
self, obj_name, obj_data, etags, expected_versions):
# only latest version appears in the listing
resp = self.client.list_objects_v2(Bucket=self.bucket_name)
objs = resp.get('Contents', [])
for obj in objs:
self._sanitize_obj_listing(obj)
self.assertEqual([{
'ETag': '"%s"' % etags[0],
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
}], objs)
# ...and that's the object that a plain GET will return
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
self.assertEqual('"%s"' % etags[0], resp['ETag'])
# but everything is layed out in the object versions listing
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
versions = []
for obj in objs:
self._sanitize_obj_listing(obj)
versions.append(obj.pop('VersionId'))
self.assertEqual([{
'ETag': '"%s"' % etags[0],
'IsLatest': True,
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
}, {
'ETag': '"%s"' % etags[1],
'IsLatest': False,
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
}], objs)
self.assertEqual(expected_versions, versions)
self.assertFalse(resp.get('DeleteMarkers'))
# delete the latest version
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=versions[0])
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
# and that pulls it out of the versions listing
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
for obj in objs:
self._sanitize_obj_listing(obj)
self.assertEqual([{
'ETag': '"%s"' % etags[1],
'IsLatest': True,
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
'VersionId': versions[1],
}], objs)
self.assertFalse(resp.get('DeleteMarkers'))
# ...and the previous version is restored
resp = self.client.list_objects_v2(Bucket=self.bucket_name)
objs = resp.get('Contents', [])
for obj in objs:
self._sanitize_obj_listing(obj)
self.assertEqual([{
'ETag': '"%s"' % etags[1],
'Key': obj_name,
'Size': len(obj_data),
'StorageClass': 'STANDARD',
}], objs)
# ...and a plain GET will now return the previous version
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
self.assertEqual('"%s"' % etags[1], resp['ETag'])
# delete the current version and it's gone
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=versions[1])
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
resp = self.client.list_object_versions(Bucket=self.bucket_name)
self.assertFalse(resp.get('Versions'))
self.assertFalse(resp.get('DeleteMarkers'))
self.assert_no_such_version(self.bucket_name, obj_name, versions[1])
self.assert_no_such_key(self.bucket_name, obj_name)
def test_delete_version_null_restored_while_versioning_enabled(self):
# verify DELETE?versionId=xxx and null restore-on-delete
# create a null version
obj_name = self.create_name('versioned-obj')
obj_data = self.create_name('some-data-0').encode('ascii')
etags = [md5(obj_data, usedforsecurity=False).hexdigest()]
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
retry(self.enable_versioning)
# create a non-null version
obj_data = self.create_name('some-data-1').encode('ascii')
etags.insert(0, md5(obj_data, usedforsecurity=False).hexdigest())
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
self._test_delete_version_previous_restored(
obj_name, obj_data, etags, [mock.ANY, 'null'])
def test_delete_version_null_restored_while_versioning_suspended(self):
# verify DELETE?versionId=xxx and null restore-on-delete
# create a null version
obj_name = self.create_name('versioned-obj')
obj_data = self.create_name('some-data-0').encode('ascii')
etags = [md5(obj_data, usedforsecurity=False).hexdigest()]
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
retry(self.enable_versioning)
# create a non-null version
obj_data = self.create_name('some-data-1').encode('ascii')
etags.insert(0, md5(obj_data, usedforsecurity=False).hexdigest())
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
retry(self.disable_versioning)
self._test_delete_version_previous_restored(
obj_name, obj_data, etags, [mock.ANY, 'null'])
def test_delete_null_version_older_version_restored(self):
# verify DELETE?versionId=null and restore-on-delete
retry(self.enable_versioning)
# create a non-null version
obj_name = self.create_name('versioned-obj')
obj_data = self.create_name('some-data-0').encode('ascii')
etags = [md5(obj_data, usedforsecurity=False).hexdigest()]
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
retry(self.disable_versioning)
# create a null version
obj_data = self.create_name('some-data-1').encode('ascii')
etags.insert(0, md5(obj_data, usedforsecurity=False).hexdigest())
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
self._test_delete_version_previous_restored(
obj_name, obj_data, etags, ['null', mock.ANY])
def _test_delete_anon_does_not_restore(self, obj_name):
orig_versions = []
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
for obj in objs:
orig_versions.append(obj.pop('VersionId'))
self.assertEqual(2, len(orig_versions))
self.assertFalse(resp.get('DeleteMarkers'))
# delete without a version id does NOT restore the previous version
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
self.assert_no_such_key(self.bucket_name, obj_name)
# versions remain
versions = []
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
for obj in objs:
versions.append(obj.pop('VersionId'))
self.assertEqual(orig_versions, versions)
# ... but there's also a delete marker
markers = resp.get('DeleteMarkers', [])
for marker in markers:
self._sanitize_obj_listing(marker)
self.assertEqual([{
'Key': obj_name,
'VersionId': mock.ANY,
'IsLatest': True,
}], markers)
self.assertNotIn(markers[0]['VersionId'], orig_versions)
def test_delete_anon_no_restore_while_versioning_enabled(self):
# verify DELETE without versionId does not restore
retry(self.enable_versioning)
# create two versions
obj_name = self.create_name('versioned-obj')
for i in range(2):
obj_data = self.create_name('some-data-%d' % i).encode('ascii')
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
self._test_delete_anon_does_not_restore(obj_name)
def test_delete_anon_no_restore_while_versioning_suspended(self):
# verify DELETE without versionId does not restore
retry(self.enable_versioning)
# create two versions
obj_name = self.create_name('versioned-obj')
for i in range(2):
obj_data = self.create_name('some-data-%d' % i).encode('ascii')
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
retry(self.disable_versioning)
self._test_delete_anon_does_not_restore(obj_name)
def test_delete_anon_current_is_null_no_restore(self):
# verify DELETE of null version without versionId does not restore
retry(self.enable_versioning)
# create version
obj_name = self.create_name('versioned-obj')
obj_data = self.create_name('some-data-0').encode('ascii')
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
retry(self.disable_versioning)
# create null version
obj_data = self.create_name('some-data-1').encode('ascii')
self.client.upload_fileobj(io.BytesIO(obj_data),
self.bucket_name, obj_name)
orig_versions = []
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
for obj in objs:
orig_versions.append(obj.pop('VersionId'))
self.assertEqual(['null', mock.ANY], orig_versions)
self.assertFalse(resp.get('DeleteMarkers'))
# delete without a version id does NOT restore the previous version
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
self.assert_no_such_key(self.bucket_name, obj_name)
# non-null version remains
versions = []
resp = self.client.list_object_versions(Bucket=self.bucket_name)
objs = resp.get('Versions', [])
for obj in objs:
versions.append(obj.pop('VersionId'))
self.assertEqual(orig_versions[1:], versions)
# ... but there's also a delete marker
markers = resp.get('DeleteMarkers', [])
for marker in markers:
self._sanitize_obj_listing(marker)
self.assertEqual([{
'Key': obj_name,
'VersionId': 'null',
'IsLatest': True,
}], markers)
def test_delete_versioned_deletes(self):
retry(self.enable_versioning)
etags = []
@@ -388,21 +1001,18 @@ class TestObjectVersioning(BaseS3TestCase):
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=marker_versions[2])
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
# since IsLatest is still marker we'll raise NoSuchKey
with self.assertRaises(ClientError) as caught:
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
expected_err = 'An error occurred (NoSuchKey) when calling the ' \
'GetObject operation: The specified key does not exist.'
self.assertEqual(expected_err, str(caught.exception))
# since IsLatest is still a delete marker we'll raise NoSuchKey
self.assert_no_such_key(self.bucket_name, obj_name)
# now delete the delete marker (IsLatest)
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=marker_versions[0])
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
# most recent version is now latest
# most recent version is now restored to be the latest
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
self.assertEqual(200, resp['ResponseMetadata']['HTTPStatusCode'])
@@ -412,14 +1022,10 @@ class TestObjectVersioning(BaseS3TestCase):
resp = self.client.delete_object(Bucket=self.bucket_name,
Key=obj_name,
VersionId=versions[0])
self.assertEqual(204, resp['ResponseMetadata']['HTTPStatusCode'])
# and object is deleted again
with self.assertRaises(ClientError) as caught:
resp = self.client.get_object(Bucket=self.bucket_name,
Key=obj_name)
expected_err = 'An error occurred (NoSuchKey) when calling the ' \
'GetObject operation: The specified key does not exist.'
self.assertEqual(expected_err, str(caught.exception))
self.assert_no_such_key(self.bucket_name, obj_name)
# delete marker IsLatest
resp = self.client.list_object_versions(Bucket=self.bucket_name)

View File

@@ -306,6 +306,17 @@ class BaseS3ApiBucket(object):
self.assertEqual(status.split()[0], '200')
self.assertEqual(headers['Location'], '/bucket')
def test_bucket_PUT_s3_compat(self):
req = Request.blank('/bucket',
environ={'REQUEST_METHOD': 'PUT'},
headers={'Authorization': 'AWS test:tester:hmac',
'Date': self.get_date_header()})
status, headers, body = self.call_s3api(req)
self.assertEqual(status.split()[0], '200')
self.assertTrue(self.swift.call_list)
self.assertEqual('true', self.swift.call_list[0].headers.get(
'X-Container-Sysmeta-S3-Compatible-Versions'))
def test_bucket_PUT_with_location(self):
self._test_bucket_PUT_with_location('CreateBucketConfiguration')

View File

@@ -1508,7 +1508,7 @@ class TestS3ApiObj(BaseS3ApiObj, S3ApiTestCase):
], self.swift.calls)
def test_object_DELETE_current_version_id(self):
resp_headers = {'X-Object-Current-Version-Id': 'null'}
resp_headers = {'X-Object-Current-Version-Id': 'none'}
self.swift.register('DELETE', '/v1/AUTH_test/bucket/object'
'?symlink=get&version-id=1574358170.12293',
swob.HTTPNoContent, resp_headers, None)
@@ -1560,7 +1560,7 @@ class TestS3ApiObj(BaseS3ApiObj, S3ApiTestCase):
self.assertEqual(status.split()[0], '501', body)
def test_object_DELETE_current_version_id_is_delete_marker(self):
resp_headers = {'X-Object-Current-Version-Id': 'null'}
resp_headers = {'X-Object-Current-Version-Id': 'none'}
self.swift.register('DELETE', '/v1/AUTH_test/bucket/object'
'?symlink=get&version-id=1574358170.12293',
swob.HTTPNoContent, resp_headers, None)
@@ -1593,7 +1593,7 @@ class TestS3ApiObj(BaseS3ApiObj, S3ApiTestCase):
], self.swift.calls)
def test_object_DELETE_current_version_id_is_missing(self):
resp_headers = {'X-Object-Current-Version-Id': 'null'}
resp_headers = {'X-Object-Current-Version-Id': 'none'}
self.swift.register('DELETE', '/v1/AUTH_test/bucket/object'
'?symlink=get&version-id=1574358170.12293',
swob.HTTPNoContent, resp_headers, None)
@@ -1640,7 +1640,7 @@ class TestS3ApiObj(BaseS3ApiObj, S3ApiTestCase):
], self.swift.calls)
def test_object_DELETE_current_version_id_GET_error(self):
resp_headers = {'X-Object-Current-Version-Id': 'null'}
resp_headers = {'X-Object-Current-Version-Id': 'none'}
self.swift.register('DELETE', '/v1/AUTH_test/bucket/object'
'?symlink=get&version-id=1574358170.12293',
swob.HTTPNoContent, resp_headers, None)
@@ -1668,7 +1668,7 @@ class TestS3ApiObj(BaseS3ApiObj, S3ApiTestCase):
], self.swift.calls)
def test_object_DELETE_current_version_id_PUT_error(self):
resp_headers = {'X-Object-Current-Version-Id': 'null'}
resp_headers = {'X-Object-Current-Version-Id': 'none'}
self.swift.register('DELETE', '/v1/AUTH_test/bucket/object'
'?symlink=get&version-id=1574358170.12293',
swob.HTTPNoContent, resp_headers, None)

View File

@@ -3622,7 +3622,7 @@ class TestObjectContext(unittest.TestCase):
def setUp(self):
app = FakeSwift()
self.obj_context = object_versioning.ObjectContext(
app, app.logger, 'v1', 'c', 'a', 'o', None, False)
app, app.logger, 'v1', 'c', 'a', 'o', None, False, False)
self.ts_iter = make_timestamp_iter()
def test_get_version(self):