Add operator tool to async-delete some or all objects in a container
Adds a tool, swift-container-deleter, that takes an account/container and optional prefix, marker, and/or end-marker; spins up an internal client; makes listing requests against the container; and pushes the found objects into the object-expirer queue with a special application/async-deleted content-type. In order to do this enqueuing efficiently, a new internal-to-the-cluster container method is introduced: UPDATE. It takes a JSON list of object entries and runs them through merge_items. The object-expirer is updated to look for work items with this content-type and skip the X-If-Deleted-At check that it would normally do. Note that the target-container's listing will continue to show the objects until data is actually deleted, bypassing some of the concerns raised in the related change about clearing out a container entirely and then deleting it. Change-Id: Ia13ee5da3d1b5c536eccaadc7a6fdcd997374443 Related-Change: I50e403dee75585fc1ff2bb385d6b2d2f13653cf8
This commit is contained in:
parent
b4c8cf192a
commit
83d0161991
@ -78,6 +78,7 @@ keystone =
|
|||||||
[entry_points]
|
[entry_points]
|
||||||
console_scripts =
|
console_scripts =
|
||||||
swift-manage-shard-ranges = swift.cli.manage_shard_ranges:main
|
swift-manage-shard-ranges = swift.cli.manage_shard_ranges:main
|
||||||
|
swift-container-deleter = swift.cli.container_deleter:main
|
||||||
|
|
||||||
paste.app_factory =
|
paste.app_factory =
|
||||||
proxy = swift.proxy.server:app_factory
|
proxy = swift.proxy.server:app_factory
|
||||||
|
174
swift/cli/container_deleter.py
Normal file
174
swift/cli/container_deleter.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||||
|
# use this file except in compliance with the License. You may obtain a copy
|
||||||
|
# of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Enqueue background jobs to delete portions of a container's namespace.
|
||||||
|
|
||||||
|
Accepts prefix, marker, and end-marker args that work as in container
|
||||||
|
listings. Objects found in the listing will be marked to be deleted
|
||||||
|
by the object-expirer; until the object is actually deleted, it will
|
||||||
|
continue to appear in listings.
|
||||||
|
|
||||||
|
If there are many objects, this operation may take some time. Stats will
|
||||||
|
periodically be emitted so you know the process hasn't hung. These will
|
||||||
|
also include the last object marked for deletion; if there is a failure,
|
||||||
|
pass this as the ``--marker`` when retrying to minimize duplicative work.
|
||||||
|
'''
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import six
|
||||||
|
import time
|
||||||
|
|
||||||
|
from swift.common.internal_client import InternalClient
|
||||||
|
from swift.common.utils import Timestamp, MD5_OF_EMPTY_STRING
|
||||||
|
from swift.obj.expirer import build_task_obj, ASYNC_DELETE_TYPE
|
||||||
|
|
||||||
|
OBJECTS_PER_UPDATE = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def make_delete_jobs(account, container, objects, timestamp):
|
||||||
|
'''
|
||||||
|
Create a list of async-delete jobs
|
||||||
|
|
||||||
|
:param account: (native or unicode string) account to delete from
|
||||||
|
:param container: (native or unicode string) container to delete from
|
||||||
|
:param objects: (list of native or unicode strings) objects to delete
|
||||||
|
:param timestamp: (Timestamp) time at which objects should be marked
|
||||||
|
deleted
|
||||||
|
:returns: list of dicts appropriate for an UPDATE request to an
|
||||||
|
expiring-object queue
|
||||||
|
'''
|
||||||
|
if six.PY2:
|
||||||
|
if isinstance(account, str):
|
||||||
|
account = account.decode('utf8')
|
||||||
|
if isinstance(container, str):
|
||||||
|
container = container.decode('utf8')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'name': build_task_obj(
|
||||||
|
timestamp, account, container,
|
||||||
|
obj.decode('utf8') if six.PY2 and isinstance(obj, str)
|
||||||
|
else obj),
|
||||||
|
'deleted': 0,
|
||||||
|
'created_at': timestamp.internal,
|
||||||
|
'etag': MD5_OF_EMPTY_STRING,
|
||||||
|
'size': 0,
|
||||||
|
'storage_policy_index': 0,
|
||||||
|
'content_type': ASYNC_DELETE_TYPE,
|
||||||
|
} for obj in objects]
|
||||||
|
|
||||||
|
|
||||||
|
def mark_for_deletion(swift, account, container, marker, end_marker,
|
||||||
|
prefix, timestamp=None, yield_time=10):
|
||||||
|
'''
|
||||||
|
Enqueue jobs to async-delete some portion of a container's namespace
|
||||||
|
|
||||||
|
:param swift: InternalClient to use
|
||||||
|
:param account: account to delete from
|
||||||
|
:param container: container to delete from
|
||||||
|
:param marker: only delete objects after this name
|
||||||
|
:param end_marker: only delete objects before this name. Use ``None`` or
|
||||||
|
empty string to delete to the end of the namespace.
|
||||||
|
:param prefix: only delete objects starting with this prefix
|
||||||
|
:param timestamp: delete all objects as of this time. If ``None``, the
|
||||||
|
current time will be used.
|
||||||
|
:param yield_time: approximate period with which intermediate results
|
||||||
|
should be returned. If ``None``, disable intermediate
|
||||||
|
results.
|
||||||
|
:returns: If ``yield_time`` is ``None``, the number of objects marked for
|
||||||
|
deletion. Otherwise, a generator that will yield out tuples of
|
||||||
|
``(number of marked objects, last object name)`` approximately
|
||||||
|
every ``yield_time`` seconds. The final tuple will have ``None``
|
||||||
|
as the second element. This form allows you to retry when an
|
||||||
|
error occurs partway through while minimizing duplicate work.
|
||||||
|
'''
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = Timestamp.now()
|
||||||
|
|
||||||
|
def enqueue_deletes():
|
||||||
|
deleted = 0
|
||||||
|
obj_iter = swift.iter_objects(
|
||||||
|
account, container,
|
||||||
|
marker=marker, end_marker=end_marker, prefix=prefix)
|
||||||
|
time_marker = time.time()
|
||||||
|
while True:
|
||||||
|
to_delete = [obj['name'] for obj in itertools.islice(
|
||||||
|
obj_iter, OBJECTS_PER_UPDATE)]
|
||||||
|
if not to_delete:
|
||||||
|
break
|
||||||
|
delete_jobs = make_delete_jobs(
|
||||||
|
account, container, to_delete, timestamp)
|
||||||
|
swift.make_request(
|
||||||
|
'UPDATE',
|
||||||
|
swift.make_path('.expiring_objects', str(int(timestamp))),
|
||||||
|
headers={'X-Backend-Allow-Method': 'UPDATE',
|
||||||
|
'X-Backend-Storage-Policy-Index': '0',
|
||||||
|
'X-Timestamp': timestamp.internal},
|
||||||
|
acceptable_statuses=(2,),
|
||||||
|
body_file=io.BytesIO(json.dumps(delete_jobs).encode('ascii')))
|
||||||
|
deleted += len(delete_jobs)
|
||||||
|
if yield_time is not None and \
|
||||||
|
time.time() - time_marker > yield_time:
|
||||||
|
yield deleted, to_delete[-1]
|
||||||
|
time_marker = time.time()
|
||||||
|
yield deleted, None
|
||||||
|
|
||||||
|
if yield_time is None:
|
||||||
|
for deleted, marker in enqueue_deletes():
|
||||||
|
if marker is None:
|
||||||
|
return deleted
|
||||||
|
else:
|
||||||
|
return enqueue_deletes()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__,
|
||||||
|
formatter_class=argparse.RawTextHelpFormatter)
|
||||||
|
parser.add_argument('--config', default='/etc/swift/internal-client.conf',
|
||||||
|
help=('internal-client config file '
|
||||||
|
'(default: /etc/swift/internal-client.conf'))
|
||||||
|
parser.add_argument('--request-tries', type=int, default=3,
|
||||||
|
help='(default: 3)')
|
||||||
|
parser.add_argument('account', help='account from which to delete')
|
||||||
|
parser.add_argument('container', help='container from which to delete')
|
||||||
|
parser.add_argument(
|
||||||
|
'--prefix', default='',
|
||||||
|
help='only delete objects with this prefix (default: none)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--marker', default='',
|
||||||
|
help='only delete objects after this marker (default: none)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--end-marker', default='',
|
||||||
|
help='only delete objects before this end-marker (default: none)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--timestamp', type=Timestamp, default=Timestamp.now(),
|
||||||
|
help='delete all objects as of this time (default: now)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
swift = InternalClient(
|
||||||
|
args.config, 'Swift Container Deleter', args.request_tries)
|
||||||
|
for deleted, marker in mark_for_deletion(
|
||||||
|
swift, args.account, args.container,
|
||||||
|
args.marker, args.end_marker, args.prefix, args.timestamp):
|
||||||
|
if marker is None:
|
||||||
|
print('Finished. Marked %d objects for deletion.' % deleted)
|
||||||
|
else:
|
||||||
|
print('Marked %d objects for deletion, through %r' % (
|
||||||
|
deleted, marker))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
@ -751,6 +751,32 @@ class ContainerController(BaseStorageServer):
|
|||||||
ret.request = req
|
ret.request = req
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
@public
|
||||||
|
@timing_stats()
|
||||||
|
def UPDATE(self, req):
|
||||||
|
"""
|
||||||
|
Handle HTTP UPDATE request (merge_items RPCs coming from the proxy.)
|
||||||
|
"""
|
||||||
|
drive, part, account, container = split_and_validate_path(req, 4)
|
||||||
|
req_timestamp = valid_timestamp(req)
|
||||||
|
try:
|
||||||
|
check_drive(self.root, drive, self.mount_check)
|
||||||
|
except ValueError:
|
||||||
|
return HTTPInsufficientStorage(drive=drive, request=req)
|
||||||
|
if not self.check_free_space(drive):
|
||||||
|
return HTTPInsufficientStorage(drive=drive, request=req)
|
||||||
|
|
||||||
|
requested_policy_index = self.get_and_validate_policy_index(req)
|
||||||
|
broker = self._get_container_broker(drive, part, account, container)
|
||||||
|
self._maybe_autocreate(broker, req_timestamp, account,
|
||||||
|
requested_policy_index)
|
||||||
|
try:
|
||||||
|
objs = json.load(req.environ['wsgi.input'])
|
||||||
|
except ValueError as err:
|
||||||
|
return HTTPBadRequest(body=str(err), content_type='text/plain')
|
||||||
|
broker.merge_items(objs)
|
||||||
|
return HTTPAccepted(request=req)
|
||||||
|
|
||||||
@public
|
@public
|
||||||
@timing_stats()
|
@timing_stats()
|
||||||
def POST(self, req):
|
def POST(self, req):
|
||||||
|
@ -28,7 +28,7 @@ from eventlet.greenpool import GreenPool
|
|||||||
from swift.common.daemon import Daemon
|
from swift.common.daemon import Daemon
|
||||||
from swift.common.internal_client import InternalClient, UnexpectedResponse
|
from swift.common.internal_client import InternalClient, UnexpectedResponse
|
||||||
from swift.common.utils import get_logger, dump_recon_cache, split_path, \
|
from swift.common.utils import get_logger, dump_recon_cache, split_path, \
|
||||||
Timestamp, config_true_value
|
Timestamp, config_true_value, normalize_delete_at_timestamp
|
||||||
from swift.common.http import HTTP_NOT_FOUND, HTTP_CONFLICT, \
|
from swift.common.http import HTTP_NOT_FOUND, HTTP_CONFLICT, \
|
||||||
HTTP_PRECONDITION_FAILED
|
HTTP_PRECONDITION_FAILED
|
||||||
from swift.common.swob import wsgi_quote, str_to_wsgi
|
from swift.common.swob import wsgi_quote, str_to_wsgi
|
||||||
@ -36,6 +36,34 @@ from swift.common.swob import wsgi_quote, str_to_wsgi
|
|||||||
from swift.container.reconciler import direct_delete_container_entry
|
from swift.container.reconciler import direct_delete_container_entry
|
||||||
|
|
||||||
MAX_OBJECTS_TO_CACHE = 100000
|
MAX_OBJECTS_TO_CACHE = 100000
|
||||||
|
ASYNC_DELETE_TYPE = 'application/async-deleted'
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_obj(timestamp, target_account, target_container,
|
||||||
|
target_obj):
|
||||||
|
"""
|
||||||
|
:return: a task object name in format of
|
||||||
|
"<timestamp>-<target_account>/<target_container>/<target_obj>"
|
||||||
|
"""
|
||||||
|
timestamp = Timestamp(timestamp)
|
||||||
|
return '%s-%s/%s/%s' % (
|
||||||
|
normalize_delete_at_timestamp(timestamp),
|
||||||
|
target_account, target_container, target_obj)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_task_obj(task_obj):
|
||||||
|
"""
|
||||||
|
:param task_obj: a task object name in format of
|
||||||
|
"<timestamp>-<target_account>/<target_container>" +
|
||||||
|
"/<target_obj>"
|
||||||
|
:return: 4-tuples of (delete_at_time, target_account, target_container,
|
||||||
|
target_obj)
|
||||||
|
"""
|
||||||
|
timestamp, target_path = task_obj.split('-', 1)
|
||||||
|
timestamp = Timestamp(timestamp)
|
||||||
|
target_account, target_container, target_obj = \
|
||||||
|
split_path('/' + target_path, 3, 3, True)
|
||||||
|
return timestamp, target_account, target_container, target_obj
|
||||||
|
|
||||||
|
|
||||||
class ObjectExpirer(Daemon):
|
class ObjectExpirer(Daemon):
|
||||||
@ -123,18 +151,7 @@ class ObjectExpirer(Daemon):
|
|||||||
self.report_last_time = time()
|
self.report_last_time = time()
|
||||||
|
|
||||||
def parse_task_obj(self, task_obj):
|
def parse_task_obj(self, task_obj):
|
||||||
"""
|
return parse_task_obj(task_obj)
|
||||||
:param task_obj: a task object name in format of
|
|
||||||
"<timestamp>-<target_account>/<target_container>" +
|
|
||||||
"/<target_obj>"
|
|
||||||
:return: 4-tuples of (delete_at_time, target_account, target_container,
|
|
||||||
target_obj)
|
|
||||||
"""
|
|
||||||
timestamp, target_path = task_obj.split('-', 1)
|
|
||||||
timestamp = Timestamp(timestamp)
|
|
||||||
target_account, target_container, target_obj = \
|
|
||||||
split_path('/' + target_path, 3, 3, True)
|
|
||||||
return timestamp, target_account, target_container, target_obj
|
|
||||||
|
|
||||||
def round_robin_order(self, task_iter):
|
def round_robin_order(self, task_iter):
|
||||||
"""
|
"""
|
||||||
@ -238,7 +255,7 @@ class ObjectExpirer(Daemon):
|
|||||||
task_object = o['name']
|
task_object = o['name']
|
||||||
try:
|
try:
|
||||||
delete_timestamp, target_account, target_container, \
|
delete_timestamp, target_account, target_container, \
|
||||||
target_object = self.parse_task_obj(task_object)
|
target_object = parse_task_obj(task_object)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.logger.exception('Unexcepted error handling task %r' %
|
self.logger.exception('Unexcepted error handling task %r' %
|
||||||
task_object)
|
task_object)
|
||||||
@ -253,12 +270,14 @@ class ObjectExpirer(Daemon):
|
|||||||
divisor) != my_index:
|
divisor) != my_index:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
is_async = o.get('content_type') == ASYNC_DELETE_TYPE
|
||||||
yield {'task_account': task_account,
|
yield {'task_account': task_account,
|
||||||
'task_container': task_container,
|
'task_container': task_container,
|
||||||
'task_object': task_object,
|
'task_object': task_object,
|
||||||
'target_path': '/'.join([
|
'target_path': '/'.join([
|
||||||
target_account, target_container, target_object]),
|
target_account, target_container, target_object]),
|
||||||
'delete_timestamp': delete_timestamp}
|
'delete_timestamp': delete_timestamp,
|
||||||
|
'is_async_delete': is_async}
|
||||||
|
|
||||||
def run_once(self, *args, **kwargs):
|
def run_once(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -390,11 +409,13 @@ class ObjectExpirer(Daemon):
|
|||||||
'process must be less than processes')
|
'process must be less than processes')
|
||||||
|
|
||||||
def delete_object(self, target_path, delete_timestamp,
|
def delete_object(self, target_path, delete_timestamp,
|
||||||
task_account, task_container, task_object):
|
task_account, task_container, task_object,
|
||||||
|
is_async_delete):
|
||||||
start_time = time()
|
start_time = time()
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
self.delete_actual_object(target_path, delete_timestamp)
|
self.delete_actual_object(target_path, delete_timestamp,
|
||||||
|
is_async_delete)
|
||||||
except UnexpectedResponse as err:
|
except UnexpectedResponse as err:
|
||||||
if err.resp.status_int not in {HTTP_NOT_FOUND,
|
if err.resp.status_int not in {HTTP_NOT_FOUND,
|
||||||
HTTP_PRECONDITION_FAILED}:
|
HTTP_PRECONDITION_FAILED}:
|
||||||
@ -431,7 +452,7 @@ class ObjectExpirer(Daemon):
|
|||||||
direct_delete_container_entry(self.swift.container_ring, task_account,
|
direct_delete_container_entry(self.swift.container_ring, task_account,
|
||||||
task_container, task_object)
|
task_container, task_object)
|
||||||
|
|
||||||
def delete_actual_object(self, actual_obj, timestamp):
|
def delete_actual_object(self, actual_obj, timestamp, is_async_delete):
|
||||||
"""
|
"""
|
||||||
Deletes the end-user object indicated by the actual object name given
|
Deletes the end-user object indicated by the actual object name given
|
||||||
'<account>/<container>/<object>' if and only if the X-Delete-At value
|
'<account>/<container>/<object>' if and only if the X-Delete-At value
|
||||||
@ -442,13 +463,19 @@ class ObjectExpirer(Daemon):
|
|||||||
:param timestamp: The swift.common.utils.Timestamp instance the
|
:param timestamp: The swift.common.utils.Timestamp instance the
|
||||||
X-Delete-At value must match to perform the actual
|
X-Delete-At value must match to perform the actual
|
||||||
delete.
|
delete.
|
||||||
|
:param is_async_delete: False if the object should be deleted because
|
||||||
|
of "normal" expiration, or True if it should
|
||||||
|
be async-deleted.
|
||||||
:raises UnexpectedResponse: if the delete was unsuccessful and
|
:raises UnexpectedResponse: if the delete was unsuccessful and
|
||||||
should be retried later
|
should be retried later
|
||||||
"""
|
"""
|
||||||
path = '/v1/' + wsgi_quote(str_to_wsgi(actual_obj.lstrip('/')))
|
path = '/v1/' + wsgi_quote(str_to_wsgi(actual_obj.lstrip('/')))
|
||||||
self.swift.make_request(
|
if is_async_delete:
|
||||||
'DELETE', path,
|
headers = {'X-Timestamp': timestamp.normal}
|
||||||
{'X-If-Delete-At': timestamp.normal,
|
acceptable_statuses = (2, HTTP_CONFLICT, HTTP_NOT_FOUND)
|
||||||
'X-Timestamp': timestamp.normal,
|
else:
|
||||||
'X-Backend-Clean-Expiring-Object-Queue': 'no'},
|
headers = {'X-Timestamp': timestamp.normal,
|
||||||
(2, HTTP_CONFLICT))
|
'X-If-Delete-At': timestamp.normal,
|
||||||
|
'X-Backend-Clean-Expiring-Object-Queue': 'no'}
|
||||||
|
acceptable_statuses = (2, HTTP_CONFLICT)
|
||||||
|
self.swift.make_request('DELETE', path, headers, acceptable_statuses)
|
||||||
|
@ -57,6 +57,7 @@ from swift.common.swob import HTTPAccepted, HTTPBadRequest, HTTPCreated, \
|
|||||||
HTTPInsufficientStorage, HTTPForbidden, HTTPException, HTTPConflict, \
|
HTTPInsufficientStorage, HTTPForbidden, HTTPException, HTTPConflict, \
|
||||||
HTTPServerError, wsgi_to_bytes, wsgi_to_str
|
HTTPServerError, wsgi_to_bytes, wsgi_to_str
|
||||||
from swift.obj.diskfile import RESERVED_DATAFILE_META, DiskFileRouter
|
from swift.obj.diskfile import RESERVED_DATAFILE_META, DiskFileRouter
|
||||||
|
from swift.obj.expirer import build_task_obj
|
||||||
|
|
||||||
|
|
||||||
def iter_mime_headers_and_bodies(wsgi_input, mime_boundary, read_chunk_size):
|
def iter_mime_headers_and_bodies(wsgi_input, mime_boundary, read_chunk_size):
|
||||||
@ -493,7 +494,7 @@ class ObjectController(BaseStorageServer):
|
|||||||
for host, contdevice in updates:
|
for host, contdevice in updates:
|
||||||
self.async_update(
|
self.async_update(
|
||||||
op, self.expiring_objects_account, delete_at_container,
|
op, self.expiring_objects_account, delete_at_container,
|
||||||
'%s-%s/%s/%s' % (delete_at, account, container, obj),
|
build_task_obj(delete_at, account, container, obj),
|
||||||
host, partition, contdevice, headers_out, objdevice,
|
host, partition, contdevice, headers_out, objdevice,
|
||||||
policy)
|
policy)
|
||||||
|
|
||||||
|
@ -1643,7 +1643,7 @@ class Controller(object):
|
|||||||
return info
|
return info
|
||||||
|
|
||||||
def _make_request(self, nodes, part, method, path, headers, query,
|
def _make_request(self, nodes, part, method, path, headers, query,
|
||||||
logger_thread_locals):
|
body, logger_thread_locals):
|
||||||
"""
|
"""
|
||||||
Iterates over the given node iterator, sending an HTTP request to one
|
Iterates over the given node iterator, sending an HTTP request to one
|
||||||
node at a time. The first non-informational, non-server-error
|
node at a time. The first non-informational, non-server-error
|
||||||
@ -1657,12 +1657,18 @@ class Controller(object):
|
|||||||
(full path ends up being /<$device>/<$part>/<$path>)
|
(full path ends up being /<$device>/<$part>/<$path>)
|
||||||
:param headers: dictionary of headers
|
:param headers: dictionary of headers
|
||||||
:param query: query string to send to the backend.
|
:param query: query string to send to the backend.
|
||||||
|
:param body: byte string to use as the request body.
|
||||||
|
Try to keep it small.
|
||||||
:param logger_thread_locals: The thread local values to be set on the
|
:param logger_thread_locals: The thread local values to be set on the
|
||||||
self.app.logger to retain transaction
|
self.app.logger to retain transaction
|
||||||
logging information.
|
logging information.
|
||||||
:returns: a swob.Response object, or None if no responses were received
|
:returns: a swob.Response object, or None if no responses were received
|
||||||
"""
|
"""
|
||||||
self.app.logger.thread_locals = logger_thread_locals
|
self.app.logger.thread_locals = logger_thread_locals
|
||||||
|
if body:
|
||||||
|
if not isinstance(body, bytes):
|
||||||
|
raise TypeError('body must be bytes, not %s' % type(body))
|
||||||
|
headers['Content-Length'] = str(len(body))
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
try:
|
try:
|
||||||
start_node_timing = time.time()
|
start_node_timing = time.time()
|
||||||
@ -1672,6 +1678,9 @@ class Controller(object):
|
|||||||
headers=headers, query_string=query)
|
headers=headers, query_string=query)
|
||||||
conn.node = node
|
conn.node = node
|
||||||
self.app.set_node_timing(node, time.time() - start_node_timing)
|
self.app.set_node_timing(node, time.time() - start_node_timing)
|
||||||
|
if body:
|
||||||
|
with Timeout(self.app.node_timeout):
|
||||||
|
conn.send(body)
|
||||||
with Timeout(self.app.node_timeout):
|
with Timeout(self.app.node_timeout):
|
||||||
resp = conn.getresponse()
|
resp = conn.getresponse()
|
||||||
if not is_informational(resp.status) and \
|
if not is_informational(resp.status) and \
|
||||||
@ -1698,7 +1707,7 @@ class Controller(object):
|
|||||||
|
|
||||||
def make_requests(self, req, ring, part, method, path, headers,
|
def make_requests(self, req, ring, part, method, path, headers,
|
||||||
query_string='', overrides=None, node_count=None,
|
query_string='', overrides=None, node_count=None,
|
||||||
node_iterator=None):
|
node_iterator=None, body=None):
|
||||||
"""
|
"""
|
||||||
Sends an HTTP request to multiple nodes and aggregates the results.
|
Sends an HTTP request to multiple nodes and aggregates the results.
|
||||||
It attempts the primary nodes concurrently, then iterates over the
|
It attempts the primary nodes concurrently, then iterates over the
|
||||||
@ -1727,7 +1736,7 @@ class Controller(object):
|
|||||||
|
|
||||||
for head in headers:
|
for head in headers:
|
||||||
pile.spawn(self._make_request, nodes, part, method, path,
|
pile.spawn(self._make_request, nodes, part, method, path,
|
||||||
head, query_string, self.app.logger.thread_locals)
|
head, query_string, body, self.app.logger.thread_locals)
|
||||||
response = []
|
response = []
|
||||||
statuses = []
|
statuses = []
|
||||||
for resp in pile:
|
for resp in pile:
|
||||||
|
@ -356,6 +356,26 @@ class ContainerController(Controller):
|
|||||||
return HTTPNotFound(request=req)
|
return HTTPNotFound(request=req)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
def UPDATE(self, req):
|
||||||
|
"""HTTP UPDATE request handler.
|
||||||
|
|
||||||
|
Method to perform bulk operations on container DBs,
|
||||||
|
similar to a merge_items REPLICATE request.
|
||||||
|
|
||||||
|
Not client facing; internal clients or middlewares must include
|
||||||
|
``X-Backend-Allow-Method: UPDATE`` header to access.
|
||||||
|
"""
|
||||||
|
container_partition, containers = self.app.container_ring.get_nodes(
|
||||||
|
self.account_name, self.container_name)
|
||||||
|
# Since this isn't client facing, expect callers to supply an index
|
||||||
|
policy_index = req.headers['X-Backend-Storage-Policy-Index']
|
||||||
|
headers = self._backend_requests(
|
||||||
|
req, len(containers), account_partition=None, accounts=[],
|
||||||
|
policy_index=policy_index)
|
||||||
|
return self.make_requests(
|
||||||
|
req, self.app.container_ring, container_partition, 'UPDATE',
|
||||||
|
req.swift_entity_path, headers, body=req.body)
|
||||||
|
|
||||||
def _backend_requests(self, req, n_outgoing, account_partition, accounts,
|
def _backend_requests(self, req, n_outgoing, account_partition, accounts,
|
||||||
policy_index=None):
|
policy_index=None):
|
||||||
additional = {'X-Timestamp': Timestamp.now().internal}
|
additional = {'X-Timestamp': Timestamp.now().internal}
|
||||||
|
@ -507,8 +507,12 @@ class Application(object):
|
|||||||
controller.trans_id = req.environ['swift.trans_id']
|
controller.trans_id = req.environ['swift.trans_id']
|
||||||
self.logger.client_ip = get_remote_client(req)
|
self.logger.client_ip = get_remote_client(req)
|
||||||
|
|
||||||
if req.method not in controller.allowed_methods:
|
allowed_methods = set(controller.allowed_methods)
|
||||||
|
if 'X-Backend-Allow-Method' in req.headers:
|
||||||
|
allowed_methods.add(req.headers['X-Backend-Allow-Method'])
|
||||||
|
if req.method not in allowed_methods:
|
||||||
return HTTPMethodNotAllowed(request=req, headers={
|
return HTTPMethodNotAllowed(request=req, headers={
|
||||||
|
# Only advertise the *controller's* allowed_methods
|
||||||
'Allow': ', '.join(controller.allowed_methods)})
|
'Allow': ', '.join(controller.allowed_methods)})
|
||||||
handler = getattr(controller, req.method)
|
handler = getattr(controller, req.method)
|
||||||
|
|
||||||
|
277
test/unit/cli/test_container_deleter.py
Normal file
277
test/unit/cli/test_container_deleter.py
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||||
|
# use this file except in compliance with the License. You may obtain a copy
|
||||||
|
# of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import mock
|
||||||
|
import six
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from swift.cli import container_deleter
|
||||||
|
from swift.common import internal_client
|
||||||
|
from swift.common import swob
|
||||||
|
from swift.common import utils
|
||||||
|
|
||||||
|
AppCall = collections.namedtuple('AppCall', [
|
||||||
|
'method', 'path', 'query', 'headers', 'body'])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeInternalClient(internal_client.InternalClient):
|
||||||
|
def __init__(self, responses):
|
||||||
|
self.resp_iter = iter(responses)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def make_request(self, method, path, headers, acceptable_statuses,
|
||||||
|
body_file=None, params=None):
|
||||||
|
if body_file is None:
|
||||||
|
body = None
|
||||||
|
else:
|
||||||
|
body = body_file.read()
|
||||||
|
path, _, query = path.partition('?')
|
||||||
|
self.calls.append(AppCall(method, path, query, headers, body))
|
||||||
|
resp = next(self.resp_iter)
|
||||||
|
if isinstance(resp, Exception):
|
||||||
|
raise resp
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
unused_responses = [r for r in self.resp_iter]
|
||||||
|
if unused_responses:
|
||||||
|
raise Exception('Unused responses: %r' % unused_responses)
|
||||||
|
|
||||||
|
|
||||||
|
class TestContainerDeleter(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
patcher = mock.patch.object(container_deleter.time, 'time',
|
||||||
|
side_effect=itertools.count())
|
||||||
|
patcher.__enter__()
|
||||||
|
self.addCleanup(patcher.__exit__)
|
||||||
|
|
||||||
|
patcher = mock.patch.object(container_deleter, 'OBJECTS_PER_UPDATE', 5)
|
||||||
|
patcher.__enter__()
|
||||||
|
self.addCleanup(patcher.__exit__)
|
||||||
|
|
||||||
|
def test_make_delete_jobs(self):
|
||||||
|
ts = '1558463777.42739'
|
||||||
|
self.assertEqual(
|
||||||
|
container_deleter.make_delete_jobs(
|
||||||
|
'acct', 'cont', ['obj1', 'obj2'],
|
||||||
|
utils.Timestamp(ts)),
|
||||||
|
[{'name': ts.split('.')[0] + '-acct/cont/obj1',
|
||||||
|
'deleted': 0,
|
||||||
|
'created_at': ts,
|
||||||
|
'etag': utils.MD5_OF_EMPTY_STRING,
|
||||||
|
'size': 0,
|
||||||
|
'storage_policy_index': 0,
|
||||||
|
'content_type': 'application/async-deleted'},
|
||||||
|
{'name': ts.split('.')[0] + '-acct/cont/obj2',
|
||||||
|
'deleted': 0,
|
||||||
|
'created_at': ts,
|
||||||
|
'etag': utils.MD5_OF_EMPTY_STRING,
|
||||||
|
'size': 0,
|
||||||
|
'storage_policy_index': 0,
|
||||||
|
'content_type': 'application/async-deleted'}])
|
||||||
|
|
||||||
|
def test_make_delete_jobs_native_utf8(self):
|
||||||
|
ts = '1558463777.42739'
|
||||||
|
uacct = acct = u'acct-\U0001f334'
|
||||||
|
ucont = cont = u'cont-\N{SNOWMAN}'
|
||||||
|
uobj1 = obj1 = u'obj-\N{GREEK CAPITAL LETTER ALPHA}'
|
||||||
|
uobj2 = obj2 = u'obj-\N{GREEK CAPITAL LETTER OMEGA}'
|
||||||
|
if six.PY2:
|
||||||
|
acct = acct.encode('utf8')
|
||||||
|
cont = cont.encode('utf8')
|
||||||
|
obj1 = obj1.encode('utf8')
|
||||||
|
obj2 = obj2.encode('utf8')
|
||||||
|
self.assertEqual(
|
||||||
|
container_deleter.make_delete_jobs(
|
||||||
|
acct, cont, [obj1, obj2], utils.Timestamp(ts)),
|
||||||
|
[{'name': u'%s-%s/%s/%s' % (ts.split('.')[0], uacct, ucont, uobj1),
|
||||||
|
'deleted': 0,
|
||||||
|
'created_at': ts,
|
||||||
|
'etag': utils.MD5_OF_EMPTY_STRING,
|
||||||
|
'size': 0,
|
||||||
|
'storage_policy_index': 0,
|
||||||
|
'content_type': 'application/async-deleted'},
|
||||||
|
{'name': u'%s-%s/%s/%s' % (ts.split('.')[0], uacct, ucont, uobj2),
|
||||||
|
'deleted': 0,
|
||||||
|
'created_at': ts,
|
||||||
|
'etag': utils.MD5_OF_EMPTY_STRING,
|
||||||
|
'size': 0,
|
||||||
|
'storage_policy_index': 0,
|
||||||
|
'content_type': 'application/async-deleted'}])
|
||||||
|
|
||||||
|
def test_make_delete_jobs_unicode_utf8(self):
|
||||||
|
ts = '1558463777.42739'
|
||||||
|
acct = u'acct-\U0001f334'
|
||||||
|
cont = u'cont-\N{SNOWMAN}'
|
||||||
|
obj1 = u'obj-\N{GREEK CAPITAL LETTER ALPHA}'
|
||||||
|
obj2 = u'obj-\N{GREEK CAPITAL LETTER OMEGA}'
|
||||||
|
self.assertEqual(
|
||||||
|
container_deleter.make_delete_jobs(
|
||||||
|
acct, cont, [obj1, obj2], utils.Timestamp(ts)),
|
||||||
|
[{'name': u'%s-%s/%s/%s' % (ts.split('.')[0], acct, cont, obj1),
|
||||||
|
'deleted': 0,
|
||||||
|
'created_at': ts,
|
||||||
|
'etag': utils.MD5_OF_EMPTY_STRING,
|
||||||
|
'size': 0,
|
||||||
|
'storage_policy_index': 0,
|
||||||
|
'content_type': 'application/async-deleted'},
|
||||||
|
{'name': u'%s-%s/%s/%s' % (ts.split('.')[0], acct, cont, obj2),
|
||||||
|
'deleted': 0,
|
||||||
|
'created_at': ts,
|
||||||
|
'etag': utils.MD5_OF_EMPTY_STRING,
|
||||||
|
'size': 0,
|
||||||
|
'storage_policy_index': 0,
|
||||||
|
'content_type': 'application/async-deleted'}])
|
||||||
|
|
||||||
|
def test_mark_for_deletion_empty_no_yield(self):
|
||||||
|
with FakeInternalClient([
|
||||||
|
swob.Response(json.dumps([
|
||||||
|
])),
|
||||||
|
]) as swift:
|
||||||
|
self.assertEqual(container_deleter.mark_for_deletion(
|
||||||
|
swift,
|
||||||
|
'account',
|
||||||
|
'container',
|
||||||
|
'marker',
|
||||||
|
'end',
|
||||||
|
'prefix',
|
||||||
|
timestamp=None,
|
||||||
|
yield_time=None,
|
||||||
|
), 0)
|
||||||
|
self.assertEqual(swift.calls, [
|
||||||
|
('GET', '/v1/account/container',
|
||||||
|
'format=json&marker=marker&end_marker=end&prefix=prefix',
|
||||||
|
{}, None),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_mark_for_deletion_empty_with_yield(self):
|
||||||
|
with FakeInternalClient([
|
||||||
|
swob.Response(json.dumps([
|
||||||
|
])),
|
||||||
|
]) as swift:
|
||||||
|
self.assertEqual(list(container_deleter.mark_for_deletion(
|
||||||
|
swift,
|
||||||
|
'account',
|
||||||
|
'container',
|
||||||
|
'marker',
|
||||||
|
'end',
|
||||||
|
'prefix',
|
||||||
|
timestamp=None,
|
||||||
|
yield_time=0.5,
|
||||||
|
)), [(0, None)])
|
||||||
|
self.assertEqual(swift.calls, [
|
||||||
|
('GET', '/v1/account/container',
|
||||||
|
'format=json&marker=marker&end_marker=end&prefix=prefix',
|
||||||
|
{}, None),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_mark_for_deletion_one_update_no_yield(self):
|
||||||
|
ts = '1558463777.42739'
|
||||||
|
with FakeInternalClient([
|
||||||
|
swob.Response(json.dumps([
|
||||||
|
{'name': 'obj1'},
|
||||||
|
{'name': 'obj2'},
|
||||||
|
{'name': 'obj3'},
|
||||||
|
])),
|
||||||
|
swob.Response(json.dumps([
|
||||||
|
])),
|
||||||
|
swob.Response(status=202),
|
||||||
|
]) as swift:
|
||||||
|
self.assertEqual(container_deleter.mark_for_deletion(
|
||||||
|
swift,
|
||||||
|
'account',
|
||||||
|
'container',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
timestamp=utils.Timestamp(ts),
|
||||||
|
yield_time=None,
|
||||||
|
), 3)
|
||||||
|
self.assertEqual(swift.calls, [
|
||||||
|
('GET', '/v1/account/container',
|
||||||
|
'format=json&marker=&end_marker=&prefix=', {}, None),
|
||||||
|
('GET', '/v1/account/container',
|
||||||
|
'format=json&marker=obj3&end_marker=&prefix=', {}, None),
|
||||||
|
('UPDATE', '/v1/.expiring_objects/' + ts.split('.')[0], '', {
|
||||||
|
'X-Backend-Allow-Method': 'UPDATE',
|
||||||
|
'X-Backend-Storage-Policy-Index': '0',
|
||||||
|
'X-Timestamp': ts}, mock.ANY),
|
||||||
|
])
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(swift.calls[-1].body),
|
||||||
|
container_deleter.make_delete_jobs(
|
||||||
|
'account', 'container', ['obj1', 'obj2', 'obj3'],
|
||||||
|
utils.Timestamp(ts)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mark_for_deletion_two_updates_with_yield(self):
|
||||||
|
ts = '1558463777.42739'
|
||||||
|
with FakeInternalClient([
|
||||||
|
swob.Response(json.dumps([
|
||||||
|
{'name': 'obj1'},
|
||||||
|
{'name': 'obj2'},
|
||||||
|
{'name': 'obj3'},
|
||||||
|
{'name': u'obj4-\N{SNOWMAN}'},
|
||||||
|
{'name': 'obj5'},
|
||||||
|
{'name': 'obj6'},
|
||||||
|
])),
|
||||||
|
swob.Response(status=202),
|
||||||
|
swob.Response(json.dumps([
|
||||||
|
])),
|
||||||
|
swob.Response(status=202),
|
||||||
|
]) as swift:
|
||||||
|
self.assertEqual(list(container_deleter.mark_for_deletion(
|
||||||
|
swift,
|
||||||
|
'account',
|
||||||
|
'container',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
timestamp=utils.Timestamp(ts),
|
||||||
|
yield_time=0,
|
||||||
|
)), [(5, 'obj5'), (6, 'obj6'), (6, None)])
|
||||||
|
self.assertEqual(swift.calls, [
|
||||||
|
('GET', '/v1/account/container',
|
||||||
|
'format=json&marker=&end_marker=&prefix=', {}, None),
|
||||||
|
('UPDATE', '/v1/.expiring_objects/' + ts.split('.')[0], '', {
|
||||||
|
'X-Backend-Allow-Method': 'UPDATE',
|
||||||
|
'X-Backend-Storage-Policy-Index': '0',
|
||||||
|
'X-Timestamp': ts}, mock.ANY),
|
||||||
|
('GET', '/v1/account/container',
|
||||||
|
'format=json&marker=obj6&end_marker=&prefix=', {}, None),
|
||||||
|
('UPDATE', '/v1/.expiring_objects/' + ts.split('.')[0], '', {
|
||||||
|
'X-Backend-Allow-Method': 'UPDATE',
|
||||||
|
'X-Backend-Storage-Policy-Index': '0',
|
||||||
|
'X-Timestamp': ts}, mock.ANY),
|
||||||
|
])
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(swift.calls[-3].body),
|
||||||
|
container_deleter.make_delete_jobs(
|
||||||
|
'account', 'container',
|
||||||
|
['obj1', 'obj2', 'obj3', u'obj4-\N{SNOWMAN}', 'obj5'],
|
||||||
|
utils.Timestamp(ts)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(swift.calls[-1].body),
|
||||||
|
container_deleter.make_delete_jobs(
|
||||||
|
'account', 'container', ['obj6'],
|
||||||
|
utils.Timestamp(ts)
|
||||||
|
)
|
||||||
|
)
|
@ -354,10 +354,8 @@ class TestContainerController(unittest.TestCase):
|
|||||||
req.content_length = 0
|
req.content_length = 0
|
||||||
resp = server_handler.OPTIONS(req)
|
resp = server_handler.OPTIONS(req)
|
||||||
self.assertEqual(200, resp.status_int)
|
self.assertEqual(200, resp.status_int)
|
||||||
for verb in 'OPTIONS GET POST PUT DELETE HEAD REPLICATE'.split():
|
self.assertEqual(sorted(resp.headers['Allow'].split(', ')), sorted(
|
||||||
self.assertTrue(
|
'OPTIONS GET POST PUT DELETE HEAD REPLICATE UPDATE'.split()))
|
||||||
verb in resp.headers['Allow'].split(', '))
|
|
||||||
self.assertEqual(len(resp.headers['Allow'].split(', ')), 7)
|
|
||||||
self.assertEqual(resp.headers['Server'],
|
self.assertEqual(resp.headers['Server'],
|
||||||
(self.controller.server_type + '/' + swift_version))
|
(self.controller.server_type + '/' + swift_version))
|
||||||
|
|
||||||
@ -1477,6 +1475,115 @@ class TestContainerController(unittest.TestCase):
|
|||||||
self.assertEqual(mock_statvfs.mock_calls,
|
self.assertEqual(mock_statvfs.mock_calls,
|
||||||
[mock.call(os.path.join(self.testdir, 'sda1'))])
|
[mock.call(os.path.join(self.testdir, 'sda1'))])
|
||||||
|
|
||||||
|
def test_UPDATE(self):
|
||||||
|
ts_iter = make_timestamp_iter()
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/a/c',
|
||||||
|
environ={'REQUEST_METHOD': 'PUT'},
|
||||||
|
headers={'X-Timestamp': next(ts_iter).internal})
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 201)
|
||||||
|
|
||||||
|
ts_iter = make_timestamp_iter()
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/a/c',
|
||||||
|
environ={'REQUEST_METHOD': 'UPDATE'},
|
||||||
|
headers={'X-Timestamp': next(ts_iter).internal},
|
||||||
|
body='[invalid json')
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 400)
|
||||||
|
|
||||||
|
ts_iter = make_timestamp_iter()
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/a/c',
|
||||||
|
environ={'REQUEST_METHOD': 'GET'},
|
||||||
|
headers={'X-Timestamp': next(ts_iter).internal})
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 204)
|
||||||
|
|
||||||
|
obj_ts = next(ts_iter)
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/a/c',
|
||||||
|
environ={'REQUEST_METHOD': 'UPDATE'},
|
||||||
|
headers={'X-Timestamp': next(ts_iter).internal},
|
||||||
|
body=json.dumps([
|
||||||
|
{'name': 'some obj', 'deleted': 0,
|
||||||
|
'created_at': obj_ts.internal,
|
||||||
|
'etag': 'whatever', 'size': 1234,
|
||||||
|
'storage_policy_index': POLICIES.default.idx,
|
||||||
|
'content_type': 'foo/bar'},
|
||||||
|
{'name': 'some tombstone', 'deleted': 1,
|
||||||
|
'created_at': next(ts_iter).internal,
|
||||||
|
'etag': 'noetag', 'size': 0,
|
||||||
|
'storage_policy_index': POLICIES.default.idx,
|
||||||
|
'content_type': 'application/deleted'},
|
||||||
|
{'name': 'wrong policy', 'deleted': 0,
|
||||||
|
'created_at': next(ts_iter).internal,
|
||||||
|
'etag': 'whatever', 'size': 6789,
|
||||||
|
'storage_policy_index': 1,
|
||||||
|
'content_type': 'foo/bar'},
|
||||||
|
]))
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 202)
|
||||||
|
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/a/c?format=json',
|
||||||
|
environ={'REQUEST_METHOD': 'GET'},
|
||||||
|
headers={'X-Timestamp': next(ts_iter).internal})
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 200)
|
||||||
|
self.assertEqual(json.loads(resp.body), [
|
||||||
|
{'name': 'some obj', 'hash': 'whatever', 'bytes': 1234,
|
||||||
|
'content_type': 'foo/bar', 'last_modified': obj_ts.isoformat},
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_UPDATE_autocreate(self):
|
||||||
|
ts_iter = make_timestamp_iter()
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/.a/c',
|
||||||
|
environ={'REQUEST_METHOD': 'GET'},
|
||||||
|
headers={'X-Timestamp': next(ts_iter).internal})
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 404)
|
||||||
|
|
||||||
|
obj_ts = next(ts_iter)
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/.a/c',
|
||||||
|
environ={'REQUEST_METHOD': 'UPDATE'},
|
||||||
|
headers={
|
||||||
|
'X-Timestamp': next(ts_iter).internal,
|
||||||
|
'X-Backend-Storage-Policy-Index': str(POLICIES.default.idx)},
|
||||||
|
body=json.dumps([
|
||||||
|
{'name': 'some obj', 'deleted': 0,
|
||||||
|
'created_at': obj_ts.internal,
|
||||||
|
'etag': 'whatever', 'size': 1234,
|
||||||
|
'storage_policy_index': POLICIES.default.idx,
|
||||||
|
'content_type': 'foo/bar'},
|
||||||
|
{'name': 'some tombstone', 'deleted': 1,
|
||||||
|
'created_at': next(ts_iter).internal,
|
||||||
|
'etag': 'noetag', 'size': 0,
|
||||||
|
'storage_policy_index': POLICIES.default.idx,
|
||||||
|
'content_type': 'application/deleted'},
|
||||||
|
{'name': 'wrong policy', 'deleted': 0,
|
||||||
|
'created_at': next(ts_iter).internal,
|
||||||
|
'etag': 'whatever', 'size': 6789,
|
||||||
|
'storage_policy_index': 1,
|
||||||
|
'content_type': 'foo/bar'},
|
||||||
|
]))
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 202, resp.body)
|
||||||
|
|
||||||
|
req = Request.blank(
|
||||||
|
'/sda1/p/.a/c?format=json',
|
||||||
|
environ={'REQUEST_METHOD': 'GET'},
|
||||||
|
headers={'X-Timestamp': next(ts_iter).internal})
|
||||||
|
resp = req.get_response(self.controller)
|
||||||
|
self.assertEqual(resp.status_int, 200)
|
||||||
|
self.assertEqual(json.loads(resp.body), [
|
||||||
|
{'name': 'some obj', 'hash': 'whatever', 'bytes': 1234,
|
||||||
|
'content_type': 'foo/bar', 'last_modified': obj_ts.isoformat},
|
||||||
|
])
|
||||||
|
|
||||||
def test_DELETE(self):
|
def test_DELETE(self):
|
||||||
ts_iter = make_timestamp_iter()
|
ts_iter = make_timestamp_iter()
|
||||||
req = Request.blank(
|
req = Request.blank(
|
||||||
@ -4591,7 +4698,7 @@ class TestNonLegacyDefaultStoragePolicy(TestContainerController):
|
|||||||
def _update_object_put_headers(self, req):
|
def _update_object_put_headers(self, req):
|
||||||
"""
|
"""
|
||||||
Add policy index headers for containers created with default policy
|
Add policy index headers for containers created with default policy
|
||||||
- which in this TestCase is 1.
|
- which in this TestCase is 2.
|
||||||
"""
|
"""
|
||||||
req.headers['X-Backend-Storage-Policy-Index'] = \
|
req.headers['X-Backend-Storage-Policy-Index'] = \
|
||||||
str(POLICIES.default.idx)
|
str(POLICIES.default.idx)
|
||||||
|
@ -254,7 +254,8 @@ class TestObjectExpirer(TestCase):
|
|||||||
self.deleted_objects = {}
|
self.deleted_objects = {}
|
||||||
|
|
||||||
def delete_object(self, target_path, delete_timestamp,
|
def delete_object(self, target_path, delete_timestamp,
|
||||||
task_account, task_container, task_object):
|
task_account, task_container, task_object,
|
||||||
|
is_async_delete):
|
||||||
if task_container not in self.deleted_objects:
|
if task_container not in self.deleted_objects:
|
||||||
self.deleted_objects[task_container] = set()
|
self.deleted_objects[task_container] = set()
|
||||||
self.deleted_objects[task_container].add(task_object)
|
self.deleted_objects[task_container].add(task_object)
|
||||||
@ -303,9 +304,10 @@ class TestObjectExpirer(TestCase):
|
|||||||
with mock.patch.object(x, 'delete_actual_object',
|
with mock.patch.object(x, 'delete_actual_object',
|
||||||
side_effect=exc) as delete_actual:
|
side_effect=exc) as delete_actual:
|
||||||
with mock.patch.object(x, 'pop_queue') as pop_queue:
|
with mock.patch.object(x, 'pop_queue') as pop_queue:
|
||||||
x.delete_object(actual_obj, ts, account, container, obj)
|
x.delete_object(actual_obj, ts, account, container, obj,
|
||||||
|
False)
|
||||||
|
|
||||||
delete_actual.assert_called_once_with(actual_obj, ts)
|
delete_actual.assert_called_once_with(actual_obj, ts, False)
|
||||||
log_lines = x.logger.get_lines_for_level('error')
|
log_lines = x.logger.get_lines_for_level('error')
|
||||||
if should_pop:
|
if should_pop:
|
||||||
pop_queue.assert_called_once_with(account, container, obj)
|
pop_queue.assert_called_once_with(account, container, obj)
|
||||||
@ -377,13 +379,14 @@ class TestObjectExpirer(TestCase):
|
|||||||
assert_parse_task_obj('1000-a/c/o', 1000, 'a', 'c', 'o')
|
assert_parse_task_obj('1000-a/c/o', 1000, 'a', 'c', 'o')
|
||||||
assert_parse_task_obj('0000-acc/con/obj', 0, 'acc', 'con', 'obj')
|
assert_parse_task_obj('0000-acc/con/obj', 0, 'acc', 'con', 'obj')
|
||||||
|
|
||||||
def make_task(self, delete_at, target):
|
def make_task(self, delete_at, target, is_async_delete=False):
|
||||||
return {
|
return {
|
||||||
'task_account': '.expiring_objects',
|
'task_account': '.expiring_objects',
|
||||||
'task_container': delete_at,
|
'task_container': delete_at,
|
||||||
'task_object': delete_at + '-' + target,
|
'task_object': delete_at + '-' + target,
|
||||||
'delete_timestamp': Timestamp(delete_at),
|
'delete_timestamp': Timestamp(delete_at),
|
||||||
'target_path': target,
|
'target_path': target,
|
||||||
|
'is_async_delete': is_async_delete,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_round_robin_order(self):
|
def test_round_robin_order(self):
|
||||||
@ -620,7 +623,7 @@ class TestObjectExpirer(TestCase):
|
|||||||
# executed tasks are with past time
|
# executed tasks are with past time
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mock_method.call_args_list,
|
mock_method.call_args_list,
|
||||||
[mock.call(target_path, self.past_time)
|
[mock.call(target_path, self.past_time, False)
|
||||||
for target_path in self.expired_target_path_list])
|
for target_path in self.expired_target_path_list])
|
||||||
|
|
||||||
def test_failed_delete_keeps_entry(self):
|
def test_failed_delete_keeps_entry(self):
|
||||||
@ -638,7 +641,7 @@ class TestObjectExpirer(TestCase):
|
|||||||
|
|
||||||
# all tasks are done
|
# all tasks are done
|
||||||
with mock.patch.object(self.expirer, 'delete_actual_object',
|
with mock.patch.object(self.expirer, 'delete_actual_object',
|
||||||
lambda o, t: None), \
|
lambda o, t, b: None), \
|
||||||
mock.patch.object(self.expirer, 'pop_queue') as mock_method:
|
mock.patch.object(self.expirer, 'pop_queue') as mock_method:
|
||||||
self.expirer.run_once()
|
self.expirer.run_once()
|
||||||
|
|
||||||
@ -653,7 +656,7 @@ class TestObjectExpirer(TestCase):
|
|||||||
self.assertEqual(self.expirer.report_objects, 0)
|
self.assertEqual(self.expirer.report_objects, 0)
|
||||||
with mock.patch('swift.obj.expirer.MAX_OBJECTS_TO_CACHE', 0), \
|
with mock.patch('swift.obj.expirer.MAX_OBJECTS_TO_CACHE', 0), \
|
||||||
mock.patch.object(self.expirer, 'delete_actual_object',
|
mock.patch.object(self.expirer, 'delete_actual_object',
|
||||||
lambda o, t: None), \
|
lambda o, t, b: None), \
|
||||||
mock.patch.object(self.expirer, 'pop_queue',
|
mock.patch.object(self.expirer, 'pop_queue',
|
||||||
lambda a, c, o: None):
|
lambda a, c, o: None):
|
||||||
self.expirer.run_once()
|
self.expirer.run_once()
|
||||||
@ -662,7 +665,8 @@ class TestObjectExpirer(TestCase):
|
|||||||
def test_delete_actual_object_gets_native_string(self):
|
def test_delete_actual_object_gets_native_string(self):
|
||||||
got_str = [False]
|
got_str = [False]
|
||||||
|
|
||||||
def delete_actual_object_test_for_string(actual_obj, timestamp):
|
def delete_actual_object_test_for_string(actual_obj, timestamp,
|
||||||
|
is_async_delete):
|
||||||
if isinstance(actual_obj, str):
|
if isinstance(actual_obj, str):
|
||||||
got_str[0] = True
|
got_str[0] = True
|
||||||
|
|
||||||
@ -681,7 +685,7 @@ class TestObjectExpirer(TestCase):
|
|||||||
def fail_delete_container(*a, **kw):
|
def fail_delete_container(*a, **kw):
|
||||||
raise Exception('failed to delete container')
|
raise Exception('failed to delete container')
|
||||||
|
|
||||||
def fail_delete_actual_object(actual_obj, timestamp):
|
def fail_delete_actual_object(actual_obj, timestamp, is_async_delete):
|
||||||
raise Exception('failed to delete actual object')
|
raise Exception('failed to delete actual object')
|
||||||
|
|
||||||
with mock.patch.object(self.fake_swift, 'delete_container',
|
with mock.patch.object(self.fake_swift, 'delete_container',
|
||||||
@ -761,10 +765,30 @@ class TestObjectExpirer(TestCase):
|
|||||||
|
|
||||||
x = expirer.ObjectExpirer({})
|
x = expirer.ObjectExpirer({})
|
||||||
ts = Timestamp('1234')
|
ts = Timestamp('1234')
|
||||||
x.delete_actual_object('/path/to/object', ts)
|
x.delete_actual_object('/path/to/object', ts, False)
|
||||||
self.assertEqual(got_env[0]['HTTP_X_IF_DELETE_AT'], ts)
|
self.assertEqual(got_env[0]['HTTP_X_IF_DELETE_AT'], ts)
|
||||||
self.assertEqual(got_env[0]['HTTP_X_TIMESTAMP'],
|
self.assertEqual(got_env[0]['HTTP_X_TIMESTAMP'],
|
||||||
got_env[0]['HTTP_X_IF_DELETE_AT'])
|
got_env[0]['HTTP_X_IF_DELETE_AT'])
|
||||||
|
self.assertEqual(
|
||||||
|
got_env[0]['HTTP_X_BACKEND_CLEAN_EXPIRING_OBJECT_QUEUE'], 'no')
|
||||||
|
|
||||||
|
def test_delete_actual_object_bulk(self):
|
||||||
|
got_env = [None]
|
||||||
|
|
||||||
|
def fake_app(env, start_response):
|
||||||
|
got_env[0] = env
|
||||||
|
start_response('204 No Content', [('Content-Length', '0')])
|
||||||
|
return []
|
||||||
|
|
||||||
|
internal_client.loadapp = lambda *a, **kw: fake_app
|
||||||
|
|
||||||
|
x = expirer.ObjectExpirer({})
|
||||||
|
ts = Timestamp('1234')
|
||||||
|
x.delete_actual_object('/path/to/object', ts, True)
|
||||||
|
self.assertNotIn('HTTP_X_IF_DELETE_AT', got_env[0])
|
||||||
|
self.assertNotIn('HTTP_X_BACKEND_CLEAN_EXPIRING_OBJECT_QUEUE',
|
||||||
|
got_env[0])
|
||||||
|
self.assertEqual(got_env[0]['HTTP_X_TIMESTAMP'], ts.internal)
|
||||||
|
|
||||||
def test_delete_actual_object_nourlquoting(self):
|
def test_delete_actual_object_nourlquoting(self):
|
||||||
# delete_actual_object should not do its own url quoting because
|
# delete_actual_object should not do its own url quoting because
|
||||||
@ -780,12 +804,41 @@ class TestObjectExpirer(TestCase):
|
|||||||
|
|
||||||
x = expirer.ObjectExpirer({})
|
x = expirer.ObjectExpirer({})
|
||||||
ts = Timestamp('1234')
|
ts = Timestamp('1234')
|
||||||
x.delete_actual_object('/path/to/object name', ts)
|
x.delete_actual_object('/path/to/object name', ts, False)
|
||||||
self.assertEqual(got_env[0]['HTTP_X_IF_DELETE_AT'], ts)
|
self.assertEqual(got_env[0]['HTTP_X_IF_DELETE_AT'], ts)
|
||||||
self.assertEqual(got_env[0]['HTTP_X_TIMESTAMP'],
|
self.assertEqual(got_env[0]['HTTP_X_TIMESTAMP'],
|
||||||
got_env[0]['HTTP_X_IF_DELETE_AT'])
|
got_env[0]['HTTP_X_IF_DELETE_AT'])
|
||||||
self.assertEqual(got_env[0]['PATH_INFO'], '/v1/path/to/object name')
|
self.assertEqual(got_env[0]['PATH_INFO'], '/v1/path/to/object name')
|
||||||
|
|
||||||
|
def test_delete_actual_object_async_returns_expected_error(self):
|
||||||
|
def do_test(test_status, should_raise):
|
||||||
|
calls = [0]
|
||||||
|
|
||||||
|
def fake_app(env, start_response):
|
||||||
|
calls[0] += 1
|
||||||
|
calls.append(env['PATH_INFO'])
|
||||||
|
start_response(test_status, [('Content-Length', '0')])
|
||||||
|
return []
|
||||||
|
|
||||||
|
internal_client.loadapp = lambda *a, **kw: fake_app
|
||||||
|
|
||||||
|
x = expirer.ObjectExpirer({})
|
||||||
|
ts = Timestamp('1234')
|
||||||
|
if should_raise:
|
||||||
|
with self.assertRaises(internal_client.UnexpectedResponse):
|
||||||
|
x.delete_actual_object('/path/to/object', ts, True)
|
||||||
|
else:
|
||||||
|
x.delete_actual_object('/path/to/object', ts, True)
|
||||||
|
self.assertEqual(calls[0], 1, calls)
|
||||||
|
|
||||||
|
# object was deleted and tombstone reaped
|
||||||
|
do_test('404 Not Found', False)
|
||||||
|
# object was overwritten *after* the original delete, or
|
||||||
|
# object was deleted but tombstone still exists, or ...
|
||||||
|
do_test('409 Conflict', False)
|
||||||
|
# Anything else, raise
|
||||||
|
do_test('400 Bad Request', True)
|
||||||
|
|
||||||
def test_delete_actual_object_returns_expected_error(self):
|
def test_delete_actual_object_returns_expected_error(self):
|
||||||
def do_test(test_status, should_raise):
|
def do_test(test_status, should_raise):
|
||||||
calls = [0]
|
calls = [0]
|
||||||
@ -801,9 +854,9 @@ class TestObjectExpirer(TestCase):
|
|||||||
ts = Timestamp('1234')
|
ts = Timestamp('1234')
|
||||||
if should_raise:
|
if should_raise:
|
||||||
with self.assertRaises(internal_client.UnexpectedResponse):
|
with self.assertRaises(internal_client.UnexpectedResponse):
|
||||||
x.delete_actual_object('/path/to/object', ts)
|
x.delete_actual_object('/path/to/object', ts, False)
|
||||||
else:
|
else:
|
||||||
x.delete_actual_object('/path/to/object', ts)
|
x.delete_actual_object('/path/to/object', ts, False)
|
||||||
self.assertEqual(calls[0], 1)
|
self.assertEqual(calls[0], 1)
|
||||||
|
|
||||||
# object was deleted and tombstone reaped
|
# object was deleted and tombstone reaped
|
||||||
@ -828,7 +881,7 @@ class TestObjectExpirer(TestCase):
|
|||||||
x = expirer.ObjectExpirer({})
|
x = expirer.ObjectExpirer({})
|
||||||
exc = None
|
exc = None
|
||||||
try:
|
try:
|
||||||
x.delete_actual_object('/path/to/object', Timestamp('1234'))
|
x.delete_actual_object('/path/to/object', Timestamp('1234'), False)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
exc = err
|
exc = err
|
||||||
finally:
|
finally:
|
||||||
@ -841,7 +894,7 @@ class TestObjectExpirer(TestCase):
|
|||||||
x = expirer.ObjectExpirer({})
|
x = expirer.ObjectExpirer({})
|
||||||
x.swift.make_request = mock.Mock()
|
x.swift.make_request = mock.Mock()
|
||||||
x.swift.make_request.return_value.status_int = 204
|
x.swift.make_request.return_value.status_int = 204
|
||||||
x.delete_actual_object(name, timestamp)
|
x.delete_actual_object(name, timestamp, False)
|
||||||
self.assertEqual(x.swift.make_request.call_count, 1)
|
self.assertEqual(x.swift.make_request.call_count, 1)
|
||||||
self.assertEqual(x.swift.make_request.call_args[0][1],
|
self.assertEqual(x.swift.make_request.call_args[0][1],
|
||||||
'/v1/' + urllib.parse.quote(name))
|
'/v1/' + urllib.parse.quote(name))
|
||||||
@ -851,7 +904,7 @@ class TestObjectExpirer(TestCase):
|
|||||||
timestamp = Timestamp('1515544858.80602')
|
timestamp = Timestamp('1515544858.80602')
|
||||||
x = expirer.ObjectExpirer({})
|
x = expirer.ObjectExpirer({})
|
||||||
x.swift.make_request = mock.MagicMock()
|
x.swift.make_request = mock.MagicMock()
|
||||||
x.delete_actual_object(name, timestamp)
|
x.delete_actual_object(name, timestamp, False)
|
||||||
self.assertEqual(x.swift.make_request.call_count, 1)
|
self.assertEqual(x.swift.make_request.call_count, 1)
|
||||||
header = 'X-Backend-Clean-Expiring-Object-Queue'
|
header = 'X-Backend-Clean-Expiring-Object-Queue'
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -321,7 +321,7 @@ class TestController(unittest.TestCase):
|
|||||||
self.controller.account_info(self.account, self.request)
|
self.controller.account_info(self.account, self.request)
|
||||||
set_http_connect(201, raise_timeout_exc=True)
|
set_http_connect(201, raise_timeout_exc=True)
|
||||||
self.controller._make_request(
|
self.controller._make_request(
|
||||||
nodes, partition, 'POST', '/', '', '',
|
nodes, partition, 'POST', '/', '', '', None,
|
||||||
self.controller.app.logger.thread_locals)
|
self.controller.app.logger.thread_locals)
|
||||||
|
|
||||||
# tests if 200 is cached and used
|
# tests if 200 is cached and used
|
||||||
@ -668,6 +668,27 @@ class TestProxyServer(unittest.TestCase):
|
|||||||
Request.blank('/v1/a', environ={'REQUEST_METHOD': '!invalid'}))
|
Request.blank('/v1/a', environ={'REQUEST_METHOD': '!invalid'}))
|
||||||
self.assertEqual(resp.status, '405 Method Not Allowed')
|
self.assertEqual(resp.status, '405 Method Not Allowed')
|
||||||
|
|
||||||
|
def test_private_method_request(self):
|
||||||
|
baseapp = proxy_server.Application({},
|
||||||
|
FakeMemcache(),
|
||||||
|
container_ring=FakeRing(),
|
||||||
|
account_ring=FakeRing())
|
||||||
|
baseapp.logger = debug_logger()
|
||||||
|
resp = baseapp.handle_request(
|
||||||
|
Request.blank('/v1/a/c', environ={'REQUEST_METHOD': 'UPDATE'}))
|
||||||
|
self.assertEqual(resp.status, '405 Method Not Allowed')
|
||||||
|
# Note that UPDATE definitely *isn't* advertised
|
||||||
|
self.assertEqual(sorted(resp.headers['Allow'].split(', ')), [
|
||||||
|
'DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'])
|
||||||
|
|
||||||
|
# But with appropriate (internal-only) overrides, you can still use it
|
||||||
|
resp = baseapp.handle_request(
|
||||||
|
Request.blank('/v1/a/c', environ={'REQUEST_METHOD': 'UPDATE'},
|
||||||
|
headers={'X-Backend-Allow-Method': 'UPDATE',
|
||||||
|
'X-Backend-Storage-Policy-Index': '0'}))
|
||||||
|
# Now we actually make the requests, but there aren't any nodes
|
||||||
|
self.assertEqual(resp.status, '503 Service Unavailable')
|
||||||
|
|
||||||
def test_calls_authorize_allow(self):
|
def test_calls_authorize_allow(self):
|
||||||
called = [False]
|
called = [False]
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user