fix x-open-expired 404 on HEAD?part-number reqs

Fixes a bug with the x-open-expired feature where our magic header
does not get copied when refetching all manifests that causes
404 on HEAD requests with part-number=N query parameter since
the object-server returns an empty response body and the
proxy needs to refetch. The fix also applies to segment GET
requests  if the segments have expired.

Change-Id: If0382d433f73cc0333bb4d0319fe1487b7783e4c
This commit is contained in:
Anish Kachinthaya 2024-04-19 15:12:31 -07:00 committed by ASHWIN A NAIR
parent 8ded39bccd
commit 4f69ab3c5d
6 changed files with 409 additions and 21 deletions

View File

@ -980,9 +980,11 @@ class SloGetContext(WSGIContext):
friendly_close(resp_iter) friendly_close(resp_iter)
del req.environ['swift.non_client_disconnect'] del req.environ['swift.non_client_disconnect']
headers_subset = ['x-auth-token', 'x-open-expired']
get_req = make_subrequest( get_req = make_subrequest(
req.environ, method='GET', req.environ, method='GET',
headers={'x-auth-token': req.headers.get('x-auth-token')}, headers={k: req.headers.get(k)
for k in headers_subset if k in req.headers},
agent='%(orig)s SLO MultipartGET', swift_source='SLO') agent='%(orig)s SLO MultipartGET', swift_source='SLO')
resp_iter = self._app_call(get_req.environ) resp_iter = self._app_call(get_req.environ)
new_resp_attrs = RespAttrs.from_headers(self._response_headers) new_resp_attrs = RespAttrs.from_headers(self._response_headers)

View File

@ -563,8 +563,8 @@ class SegmentedIterable(object):
path = quote(seg_path) + '?multipart-manifest=get' path = quote(seg_path) + '?multipart-manifest=get'
seg_req = make_subrequest( seg_req = make_subrequest(
self.req.environ, path=path, method='GET', self.req.environ, path=path, method='GET',
headers={'x-auth-token': self.req.headers.get( headers={h: self.req.headers.get(h)
'x-auth-token')}, for h in ('x-auth-token', 'x-open-expired')},
agent=('%(orig)s ' + self.ua_suffix), agent=('%(orig)s ' + self.ua_suffix),
swift_source=self.swift_source) swift_source=self.swift_source)

View File

@ -579,6 +579,7 @@ def in_process_setup(the_object_server=object_server):
'container_update_timeout': '3', 'container_update_timeout': '3',
'allow_account_management': 'true', 'allow_account_management': 'true',
'account_autocreate': 'true', 'account_autocreate': 'true',
'allow_open_expired': 'true',
'allow_versions': 'True', 'allow_versions': 'True',
'allow_versioned_writes': 'True', 'allow_versioned_writes': 'True',
# Below are values used by the functional test framework, as well as # Below are values used by the functional test framework, as well as

View File

@ -18,6 +18,7 @@ import base64
import email.parser import email.parser
import itertools import itertools
import json import json
import time
from copy import deepcopy from copy import deepcopy
from unittest import SkipTest from unittest import SkipTest
@ -25,8 +26,9 @@ import six
from six.moves import urllib from six.moves import urllib
from swift.common.swob import normalize_etag from swift.common.swob import normalize_etag
from swift.common.utils import md5 from swift.common.utils import md5, config_true_value
from test.functional import check_response, retry
import test.functional as tf import test.functional as tf
from test.functional import cluster_info from test.functional import cluster_info
from test.functional.tests import Utils, Base, Base2, BaseEnv from test.functional.tests import Utils, Base, Base2, BaseEnv
@ -51,6 +53,12 @@ def group_file_contents(file_contents):
for char, grp in itertools.groupby(byte_iter)] for char, grp in itertools.groupby(byte_iter)]
def md5hex(s):
if not isinstance(s, bytes):
s = s.encode('ascii')
return md5(s, usedforsecurity=False).hexdigest()
class TestSloEnv(BaseEnv): class TestSloEnv(BaseEnv):
slo_enabled = None # tri-state: None initially, then True/False slo_enabled = None # tri-state: None initially, then True/False
@ -450,6 +458,163 @@ class TestSlo(Base):
self.assertEqual(headers['x-parts-count'], '5') self.assertEqual(headers['x-parts-count'], '5')
start = end + 1 start = end + 1
def test_x_delete_at_with_part_number_and_open_expired(self):
cont_name = self.env.account2.container(self.env.container.name)
allow_open_expired = config_true_value(tf.cluster_info['swift'].get(
'allow_open_expired', 'false'))
if not allow_open_expired:
raise SkipTest('allow_open_expired is disabled')
# data for segments
segments = [b'one', b'two', b'three', b'four']
etags = []
for segment in segments:
etags.append(md5hex(segment))
def put_manifest(url, token, parsed, conn, object_segments):
now = int(time.time())
delete_time = now + 2
manifest_data = []
start = 0
for segment_object in range(len(object_segments)):
size = len(object_segments[segment_object])
end = start + size - 1
manifest_data.append({
'path': '/%s/segments/%s' % (cont_name,
str(segment_object)),
'etag': etags[segment_object],
'size_bytes': size,
})
start = end + 1
conn.request(
'PUT',
'%s/%s/manifest?multipart-manifest=put' % (parsed.path,
cont_name),
body=json.dumps(manifest_data),
headers={
'X-Auth-Token': token,
'X-Delete-At': delete_time,
'X-Static-Large-Object': 'true',
'Content-Type': 'application/json'
}
)
resp = check_response(conn)
body = resp.read()
self.assertEqual(resp.status, 201,
"Response status is not 201: %s" % body)
def put_segments(url, token, parsed, conn, object_segments):
now = int(time.time())
delete_time = now + 2
for objnum in range(len(object_segments)):
conn.request('PUT', '%s/%s/segments/%s' % (
parsed.path,
cont_name,
str(objnum)),
body=object_segments[objnum],
headers={
'X-Auth-Token': token,
'X-Delete-At': delete_time})
resp = check_response(conn)
body = resp.read()
self.assertEqual(resp.status, 201,
"Response status is not 201: %s" % body)
retry(put_segments, segments)
retry(put_manifest, segments)
# get the manifest
def get_manifest(url, token, parsed, conn, extra_headers=None):
headers = {'X-Auth-Token': token}
if extra_headers:
headers.update(extra_headers)
conn.request(
'GET',
'%s/%s/manifest?multipart-manifest=get' %
(parsed.path, cont_name),
'', headers)
return check_response(conn)
resp = retry(get_manifest)
self.assertEqual(resp.status, 200)
# wait for the manifest to expire
# the objects will also have expired at the same time
# since their x-delete-at times are the same
time.sleep(3)
resp = retry(get_manifest)
resp.read()
# check to see manifest has expired
self.assertEqual(resp.status, 404, resp.headers.get('x-trans-id'))
def get_or_head_part(url, token, parsed, conn,
extra_headers=None, method='GET',
part_number=None):
headers = {'X-Auth-Token': token}
if extra_headers:
headers.update(extra_headers)
conn.request(method, '%s/%s/manifest?part-number=%s' % (
parsed.path,
cont_name,
part_number
), '', headers)
return check_response(conn)
resp = retry(get_manifest, extra_headers={'X-Open-Expired': True})
resp.read()
# read the expired object with magic x-open-expired header
self.assertEqual(resp.status, 200)
for objnum in range(len(segments)):
part_num = str(objnum + 1)
resp = retry(get_or_head_part,
extra_headers={'X-Open-Expired': True},
part_number=part_num,)
resp.read()
self.assertEqual(resp.status, 206)
for objnum in range(len(segments)):
part_num = str(objnum + 1)
resp = retry(get_or_head_part,
extra_headers={'X-Open-Expired': True},
method='HEAD', part_number=part_num)
resp.read()
self.assertEqual(resp.status, 206)
# no x-open-expired case and it should 404
for objnum in range(len(segments)):
part_num = str(objnum + 1)
resp = retry(get_or_head_part,
method='HEAD', part_number=part_num)
resp.read()
self.assertEqual(resp.status, 404)
# same situation here
for objnum in range(len(segments)):
part_num = str(objnum + 1)
resp = retry(get_or_head_part,
method='GET', part_number=part_num)
resp.read()
self.assertEqual(resp.status, 404)
def head_manifest(url, token, parsed, conn, extra_headers=None):
headers = {'X-Auth-Token': token}
if extra_headers:
headers.update(extra_headers)
conn.request('HEAD', '%s/%s/manifest' % (parsed.path,
cont_name),
'', headers)
return check_response(conn)
resp = retry(head_manifest, extra_headers={'X-Open-Expired': True})
resp.read()
# head expired object with magic x-open-expired header
self.assertEqual(resp.status, 200)
def test_slo_container_listing(self): def test_slo_container_listing(self):
# the listing object size should equal the sum of the size of the # the listing object size should equal the sum of the size of the
# segments, not the size of the manifest body # segments, not the size of the manifest body

View File

@ -410,6 +410,129 @@ class TestObjectExpirer(ReplProbeTest):
headers={'X-Open-Expired': True}) headers={'X-Open-Expired': True})
self.assertEqual(e.exception.http_status, 404) self.assertEqual(e.exception.http_status, 404)
def _setup_test_slo_object(self):
segment_container = self.container_name + '_segments'
client.put_container(self.url, self.token, self.container_name, {})
client.put_container(self.url, self.token, segment_container, {})
client.put_object(self.url, self.token,
segment_container, 'segment_1', b'12')
client.put_object(self.url, self.token,
segment_container, 'segment_2', b'5678')
client.put_object(
self.url, self.token, self.container_name, 'slo', json.dumps([
{'path': segment_container + '/segment_1'},
{'data': 'Cg=='},
{'path': segment_container + '/segment_2'},
]), query_string='multipart-manifest=put')
_, body = client.get_object(self.url, self.token,
self.container_name, 'slo')
self.assertEqual(body, b'12\n5678')
return segment_container, self.container_name
def test_open_expired_enabled_with_part_num(self):
allow_open_expired = config_true_value(
self.cluster_info['swift'].get('allow_open_expired')
)
if not allow_open_expired:
raise unittest.SkipTest(
"allow_open_expired is disabled in this swift cluster"
)
seg_container, container_name = self._setup_test_slo_object()
now = time.time()
delete_at = int(now + 1)
client.post_object(
self.url, self.token, container_name, 'slo',
headers={
'X-Delete-At': str(delete_at),
'X-Object-Meta-Test': 'foo'
}
)
# make sure auto-created containers get in the account listing
Manager(['container-updater']).once()
# sleep until after expired but not reaped
while time.time() <= delete_at:
time.sleep(0.1)
# should get a 404, object is expired
while True:
try:
client.head_object(self.url, self.token, container_name, 'slo')
time.sleep(1) # Wait for a short period before trying again
except ClientException as e:
# check if the object is expired
if e.http_status == 404:
break # The object is expired, so we can exit the loop
resp_headers = client.head_object(
self.url, self.token, container_name, 'slo',
headers={'X-Open-Expired': True},
query_string='part-number=1'
)
self.assertEqual(resp_headers.get('x-object-meta-test'), 'foo')
self.assertEqual(resp_headers.get('content-range'), 'bytes 0-1/7')
self.assertEqual(resp_headers.get('content-length'), '2')
self.assertEqual(resp_headers.get('x-parts-count'), '3')
self.assertEqual(resp_headers.get('x-static-large-object'), 'True')
self.assertEqual(resp_headers.get('accept-ranges'), 'bytes')
with self.assertRaises(ClientException) as e:
client.head_object(self.url, self.token, container_name, 'slo')
self.assertEqual(e.exception.http_status, 404)
now = time.time()
delete_at = int(now + 2)
for seg_obj_name in ['segment_1', 'segment_2']:
client.post_object(
self.url, self.token, seg_container, seg_obj_name,
headers={
'X-Open-Expired': True,
'X-Segment-Meta-Test': 'segment-foo',
'X-Delete-At': str(delete_at)
}
)
# make sure auto-created containers get in the account listing
Manager(['container-updater']).once()
while time.time() <= delete_at:
time.sleep(0.1)
# should get a 404, segment object is expired
with self.assertRaises(ClientException) as e:
client.head_object(self.url, self.token, seg_container,
'segment_2')
self.assertEqual(e.exception.http_status, 404)
# magic of x-open-expired
resp_headers = client.head_object(
self.url, self.token, seg_container, 'segment_2',
headers={'X-Open-Expired': True},
query_string='part-number=1'
)
# keep in mind that the segment object is expired
self.assertEqual(resp_headers.get('content-length'), '4')
self.assertTrue(time.time() > delete_at)
# expirer runs to reap the whichever object was set for expiry
self.expirer.once()
for seg_obj_name in ['segment_1', 'segment_2']:
# should get a 404 even with x-open-expired since object is reaped
with self.assertRaises(ClientException) as e:
client.head_object(
self.url, self.token, seg_container, seg_obj_name,
headers={'X-Open-Expired': True},
query_string='part-number=1'
)
self.assertEqual(e.exception.http_status, 404)
def test_open_expired_disabled(self): def test_open_expired_disabled(self):
# When the global configuration option allow_open_expired is set to # When the global configuration option allow_open_expired is set to
@ -681,22 +804,7 @@ class TestObjectExpirer(ReplProbeTest):
if not self.cluster_info.get('slo', {}).get('allow_async_delete'): if not self.cluster_info.get('slo', {}).get('allow_async_delete'):
raise unittest.SkipTest('allow_async_delete not enabled') raise unittest.SkipTest('allow_async_delete not enabled')
segment_container = self.container_name + '_segments' segment_container, _ = self._setup_test_slo_object()
client.put_container(self.url, self.token, self.container_name, {})
client.put_container(self.url, self.token, segment_container, {})
client.put_object(self.url, self.token,
segment_container, 'segment_1', b'1234')
client.put_object(self.url, self.token,
segment_container, 'segment_2', b'5678')
client.put_object(
self.url, self.token, self.container_name, 'slo', json.dumps([
{'path': segment_container + '/segment_1'},
{'data': 'Cg=='},
{'path': segment_container + '/segment_2'},
]), query_string='multipart-manifest=put')
_, body = client.get_object(self.url, self.token,
self.container_name, 'slo')
self.assertEqual(body, b'1234\n5678')
client.delete_object( client.delete_object(
self.url, self.token, self.container_name, 'slo', self.url, self.token, self.container_name, 'slo',
@ -717,7 +825,7 @@ class TestObjectExpirer(ReplProbeTest):
['segment_1', 'segment_2']) ['segment_1', 'segment_2'])
_, body = client.get_object(self.url, self.token, _, body = client.get_object(self.url, self.token,
segment_container, 'segment_1') segment_container, 'segment_1')
self.assertEqual(body, b'1234') self.assertEqual(body, b'12')
_, body = client.get_object(self.url, self.token, _, body = client.get_object(self.url, self.token,
segment_container, 'segment_2') segment_container, 'segment_2')
self.assertEqual(body, b'5678') self.assertEqual(body, b'5678')

View File

@ -1981,6 +1981,18 @@ class SloGETorHEADTestCase(SloTestCase):
extra_headers={'X-Object-Meta-Nature': 'Regular'}, extra_headers={'X-Object-Meta-Nature': 'Regular'},
container='gettest') container='gettest')
def _setup_manifest_zero_byte(self):
"""
This is a zero-byte manifest.
"""
_single_segment_manifest = [
{'name': '/gettest/zero', 'hash': md5hex(''), 'bytes': '0',
'content_type': 'text/plain'},
]
self._setup_manifest(
'zero-byte', _single_segment_manifest,
container='gettest')
def _setup_manifest_data(self): def _setup_manifest_data(self):
_data_manifest = [ _data_manifest = [
{ {
@ -2011,6 +2023,21 @@ class SloGETorHEADTestCase(SloTestCase):
'X-Object-Meta-Plant': 'Ficus', 'X-Object-Meta-Plant': 'Ficus',
}, container='gettest') }, container='gettest')
def _setup_manifest_bc_expires(self):
"""
This manifest's segments are all regular objects due to expire.
"""
_bc_expires_manifest = [
{'name': '/gettest/b_5', 'hash': md5hex('b' * 5), 'bytes': '5',
'content_type': 'text/plain'},
{'name': '/gettest/c_10', 'hash': md5hex('c' * 10), 'bytes': '10',
'content_type': 'text/plain'}
]
self._setup_manifest('bc-expires', _bc_expires_manifest,
extra_headers={'X-Object-Meta-Plant':
'Ficus-Expires'},
container='gettest')
def _setup_manifest_abcd(self): def _setup_manifest_abcd(self):
""" """
This manifest uses manifest-bc as a sub-manifest! This manifest uses manifest-bc as a sub-manifest!
@ -5703,6 +5730,8 @@ class TestPartNumber(SloGETorHEADTestCase):
self._setup_manifest_abcd_subranges() self._setup_manifest_abcd_subranges()
self._setup_manifest_aabbccdd() self._setup_manifest_aabbccdd()
self._setup_manifest_single_segment() self._setup_manifest_single_segment()
self._setup_manifest_zero_byte()
self._setup_manifest_bc_expires()
# this b_50 object doesn't follow the alphabet convention # this b_50 object doesn't follow the alphabet convention
self.app.register( self.app.register(
@ -5711,6 +5740,11 @@ class TestPartNumber(SloGETorHEADTestCase):
'Etag': md5hex('b' * 50)}, 'Etag': md5hex('b' * 50)},
'b' * 50) 'b' * 50)
# Setup POST req separately for expiring manifest
self.app.register('POST',
'/v1/AUTH_test/gettest/manifest-bc-expires',
swob.HTTPAccepted, {})
self._setup_manifest_data() self._setup_manifest_data()
def test_head_part_number(self): def test_head_part_number(self):
@ -5768,6 +5802,58 @@ class TestPartNumber(SloGETorHEADTestCase):
] ]
self.assertEqual(self.app.calls, expected_calls) self.assertEqual(self.app.calls, expected_calls)
def test_get_manifest_with_x_open_expired_part_num(self):
req = Request.blank(
'/v1/AUTH_test/gettest/manifest-bc-expires'
'?multipart-manifest=get',
environ={'REQUEST_METHOD': 'GET'})
captured_calls = []
orig_call = FakeSwift.__call__
def pseudo_middleware(app, env, start_response):
captured_calls.append((env['REQUEST_METHOD'], env['PATH_INFO']))
# pretend another middleware modified the path
# note: for convenience, the path "modification" actually results
# in one of the pre-registered paths
env['PATH_INFO'] += ''
return orig_call(app, env, start_response)
with patch.object(FakeSwift, '__call__', pseudo_middleware):
status, headers, body = self.call_slo(req)
self.assertEqual(status, '200 OK')
self.assertEqual([('GET',
'/v1/AUTH_test/gettest/manifest-bc-expires')],
captured_calls)
t = str(int(time.time()))
req = Request.blank(
'/v1/AUTH_test/gettest/manifest-bc-expires',
environ={'REQUEST_METHOD': 'POST'},
headers={'X-Delete-At': t}
)
with patch.object(FakeSwift, '__call__', pseudo_middleware):
status, headers, body = self.call_slo(req)
self.assertEqual(status, '202 Accepted')
req = Request.blank(
'/v1/AUTH_test/gettest/manifest-bc-expires?part-number=1',
environ={'REQUEST_METHOD': 'HEAD'},
headers={'x-open-expired': 'true'})
with patch.object(FakeSwift, '__call__', pseudo_middleware):
status, headers, body = self.call_slo(req)
self.assertEqual(status, '206 Partial Content')
self.assertEqual(headers['X-Static-Large-Object'], 'true')
self.assertEqual(headers['Etag'], '"%s"' %
self.manifest_bc_expires_slo_etag)
self.assertEqual(self.app.call_count, 4)
self.assertTrue(self.app.calls_with_headers[2][2]['X-Open-Expired'])
def test_get_part_number(self): def test_get_part_number(self):
# part number 1 is b_10 # part number 1 is b_10
req = Request.blank( req = Request.blank(
@ -6028,6 +6114,32 @@ class TestPartNumber(SloGETorHEADTestCase):
] ]
self.assertEqual(expected_calls, self.app.calls) self.assertEqual(expected_calls, self.app.calls)
def test_part_number_zero_byte_manifest(self):
part_num = 1
req = Request.blank(
'/v1/AUTH_test/gettest/manifest-zero-byte?'
'partNumber=%s' % part_num,
method='HEAD')
status, headers, body = self.call_slo(req)
self.assertEqual(status, '200 OK')
self.assertEqual(headers['Etag'],
'"%s"' % self.manifest_zero_byte_slo_etag)
self.assertEqual(headers['Content-Length'], '0')
self.assertEqual(headers['X-Static-Large-Object'], 'true')
self.assertEqual(headers['X-Manifest-Etag'],
self.manifest_zero_byte_json_md5)
self.assertEqual(body, b'') # it's a HEAD request, after all
expected_app_calls = [('HEAD',
'/v1/AUTH_test/gettest/manifest-zero-byte?'
'partNumber=%s' % part_num)]
if not self.modern_manifest_headers:
expected_app_calls.append((
'GET',
'/v1/AUTH_test/gettest/manifest-zero-byte?'
'partNumber=%s' % part_num))
self.assertEqual(self.app.calls, expected_app_calls)
def test_part_number_zero_invalid_on_subrange(self): def test_part_number_zero_invalid_on_subrange(self):
# either manifest, doesn't matter, part-number=0 is always invalid # either manifest, doesn't matter, part-number=0 is always invalid
req = Request.blank( req = Request.blank(