From 2014cdb9066e273cf791f38b1c8247427c76cfa9 Mon Sep 17 00:00:00 2001 From: Prashanth Pai Date: Tue, 28 Jan 2014 12:13:33 +0530 Subject: [PATCH] Add support for Object Expiration feature Preventing access to expired objects ------------------------------------ Re-enabled accepting X-Delete-At and X-Delete-After headers. During a GET on an expired object, DiskFileExpired is raised by DiskFile class. This will result in object-server returning HTTPNotFound (404) to the client. Tracking objects to be deleted ------------------------------ Objects to be deleted are tracked using "tracker objects". These are PUT into a special account(a volume, for now). These zero size "tracker objects" have names that contain: * Expiration timestamp * Path of the actual object to be deleted Deleting actual objects from GlusterFS volume --------------------------------------------- The object-expirer daemon runs a pass once every X seconds. For every pass it makes, it queries the special account for "tracker objects". Based on (timestamp, path) present in name of "tracker objects", object-expirer then deletes the actual object and the corresponding tracker object. To run object-expirer forever: swift-init object-expirer start To run just once: swift-object-expirer -o -v /etc/swift/object-expirer.conf Caveat/Limitation: Object-expirer needs a separate account(volume) that is not used by other services like gswauth. By default, this volume is named "gsexpiring" and is configurable. More info about object expiration: http://docs.openstack.org/developer/swift/overview_expiring_objects.html Change-Id: I876995bf4f16ef4bfdff901561e0558ecf1dc38f Signed-off-by: Prashanth Pai Reviewed-on: http://review.gluster.org/6891 Tested-by: Chetan Risbud Reviewed-by: pushpesh sharma Tested-by: pushpesh sharma Reviewed-by: Chetan Risbud --- doc/markdown/object-expiration.md | 75 +++ etc/account-server.conf-gluster | 3 + etc/container-server.conf-gluster | 3 + etc/object-expirer.conf-gluster | 27 + etc/object-server.conf-gluster | 4 + etc/proxy-server.conf-gluster | 4 + gluster/swift/common/constraints.py | 2 +- gluster/swift/obj/diskfile.py | 22 +- gluster/swift/obj/server.py | 102 +++- test/functional/gluster_swift_tests.py | 8 + test/unit/common/test_constraints.py | 16 + test/unit/obj/test_expirer.py | 701 +++++++++++++++++++++++++ test/unit/proxy/test_server.py | 10 - 13 files changed, 954 insertions(+), 23 deletions(-) create mode 100644 doc/markdown/object-expiration.md create mode 100644 etc/object-expirer.conf-gluster create mode 100644 test/unit/obj/test_expirer.py diff --git a/doc/markdown/object-expiration.md b/doc/markdown/object-expiration.md new file mode 100644 index 0000000..a61818a --- /dev/null +++ b/doc/markdown/object-expiration.md @@ -0,0 +1,75 @@ +# Object Expiration + +## Contents +* [Overview](#overview) +* [Setup](#setup) +* [Using object expiration](#using) +* [Running object-expirer daemon](#running-daemon) + + +## Overview +The Object Expiration feature offers **scheduled deletion of objects**. The client would use the *X-Delete-At* or *X-Delete-After* headers during an object PUT or POST and the cluster would automatically quit serving that object at the specified time and would shortly thereafter remove the object from the GlusterFS volume. + +Expired objects however do appear in container listings until they are deleted by object-expirer daemon. This behaviour is expected: https://bugs.launchpad.net/swift/+bug/1069849 + + +## Setup +Object expirer uses a seprate account (a GlusterFS volume, for now, until multiple accounts per volume is implemented) named *gsexpiring*. You will have to [create a GlusterFS volume](quick_start_guide.md#gluster-volume-setup) by that name. + +Object-expirer uses the */etc/swift/object-expirer.conf* configuration file. Make sure that it exists. If not, you can copy it from */etc* directory of gluster-swift source repo. + + +## Using object expiration + +**PUT an object with X-Delete-At header using curl** + +~~~ +curl -v -X PUT -H 'X-Delete-At: 1392013619' http://127.0.0.1:8080/v1/AUTH_test/container1/object1 -T ./localfile +~~~ + +**PUT an object with X-Delete-At header using swift client** + +~~~ +swift --os-auth-token=AUTH_tk99a39aecc3dd4f80b2b1e801d00df846 --os-storage-url=http://127.0.0.1:8080/v1/AUTH_test upload container1 ./localfile --header 'X-Delete-At: 1392013619' +~~~ + +where *X-Delete-At* header takes a Unix Epoch timestamp in integer. For example, the current time in Epoch notation can be found by running this command: + +~~~ +date +%s +~~~ + + +**PUT an object with X-Delete-After header using curl** + +~~~ +curl -v -X PUT -H 'X-Delete-After: 3600' http://127.0.0.1:8080/v1/AUTH_test/container1/object1 -T ./localfile +~~~ + +**PUT an object with X-Delete-At header using swift client** + +~~~ +swift --os-auth-token=AUTH_tk99a39aecc3dd4f80b2b1e801d00df846 --os-storage-url=http://127.0.0.1:8080/v1/AUTH_test upload container1 ./localfile --header 'X-Delete-After: 3600' +~~~ + +where *X-Delete-After* header takes a integer number of seconds, after which the object expires. The proxy server that receives the request will convert this header into an X-Delete-At header using its current time plus the value given. + + +## Running object-expirer daemon +The object-expirer daemon runs a pass once every X seconds (configurable using *interval* option in config file). For every pass it makes, it queries the *gsexpiring* account for "tracker objects". Based on (timestamp, path) present in name of "tracker objects", object-expirer then deletes the actual object and the corresponding tracker object. + + +To run object-expirer forever as a daemon: +~~~ +swift-init object-expirer start +~~~ + +To run just once: +~~~ +swift-object-expirer -o -v /etc/swift/object-expirer.conf +~~~ + +**For more information, visit:** +http://docs.openstack.org/developer/swift/overview_expiring_objects.html + + diff --git a/etc/account-server.conf-gluster b/etc/account-server.conf-gluster index 9ad458a..53cae14 100644 --- a/etc/account-server.conf-gluster +++ b/etc/account-server.conf-gluster @@ -28,6 +28,9 @@ use = egg:gluster_swift#account user = root log_facility = LOG_LOCAL2 log_level = WARN +# The following parameter is used by object-expirer and needs to be same +# across all conf files! +auto_create_account_prefix = gs # # After ensuring things are running in a stable manner, you can turn off # normal request logging for the account server to unclutter the log diff --git a/etc/container-server.conf-gluster b/etc/container-server.conf-gluster index a406b4d..3136bd9 100644 --- a/etc/container-server.conf-gluster +++ b/etc/container-server.conf-gluster @@ -28,6 +28,9 @@ use = egg:gluster_swift#container user = root log_facility = LOG_LOCAL2 log_level = WARN +# The following parameters is used by object-expirer and needs to be same +# across all conf files! +auto_create_account_prefix = gs # # After ensuring things are running in a stable manner, you can turn off # normal request logging for the container server to unclutter the log diff --git a/etc/object-expirer.conf-gluster b/etc/object-expirer.conf-gluster new file mode 100644 index 0000000..4449ee2 --- /dev/null +++ b/etc/object-expirer.conf-gluster @@ -0,0 +1,27 @@ +#TODO: Add documentation to explain various options +#For now, refer: https://github.com/openstack/swift/blob/master/etc/object-expirer.conf-sample + +[DEFAULT] + +[object-expirer] +user = root +log_facility = LOG_LOCAL2 +log_level = DEBUG +# The following parameters are used by object-expirer and needs to be same +# across all conf files! +auto_create_account_prefix = gs +expiring_objects_account_name = expiring + +interval = 30 + +[pipeline:main] +pipeline = catch_errors cache proxy-server + +[app:proxy-server] +use = egg:gluster_swift#proxy + +[filter:cache] +use = egg:swift#memcache + +[filter:catch_errors] +use = egg:swift#catch_errors diff --git a/etc/object-server.conf-gluster b/etc/object-server.conf-gluster index d10d282..d8d06c7 100644 --- a/etc/object-server.conf-gluster +++ b/etc/object-server.conf-gluster @@ -34,6 +34,10 @@ use = egg:gluster_swift#object user = root log_facility = LOG_LOCAL2 log_level = WARN +# The following parameters are used by object-expirer and needs to be same +# across all conf files! +auto_create_account_prefix = gs +expiring_objects_account_name = expiring # # For performance, after ensuring things are running in a stable manner, you # can turn off normal request logging for the object server to reduce the diff --git a/etc/proxy-server.conf-gluster b/etc/proxy-server.conf-gluster index 92f8083..7996a5d 100644 --- a/etc/proxy-server.conf-gluster +++ b/etc/proxy-server.conf-gluster @@ -16,6 +16,10 @@ log_level = WARN # or delete an account. So leave this off. allow_account_management = false account_autocreate = true +# The following parameters are used by object-expirer and needs to be same +# across all conf files! +auto_create_account_prefix = gs +expiring_objects_account_name = expiring # Ensure the proxy server uses fast-POSTs since we don't need to make a copy # of the entire object given that all metadata is stored in the object # extended attributes (no .meta file used after creation) and no container diff --git a/gluster/swift/common/constraints.py b/gluster/swift/common/constraints.py index 7681c49..80616f2 100644 --- a/gluster/swift/common/constraints.py +++ b/gluster/swift/common/constraints.py @@ -23,7 +23,7 @@ import swift.common.ring as _ring from gluster.swift.common import Glusterfs, ring MAX_OBJECT_NAME_COMPONENT_LENGTH = 255 -UNSUPPORTED_HEADERS = ['x-delete-at', 'x-delete-after'] +UNSUPPORTED_HEADERS = [] def set_object_name_component_length(len=None): diff --git a/gluster/swift/obj/diskfile.py b/gluster/swift/obj/diskfile.py index 852f69f..b3e91bc 100644 --- a/gluster/swift/obj/diskfile.py +++ b/gluster/swift/obj/diskfile.py @@ -23,6 +23,7 @@ try: except ImportError: import random import logging +import time from collections import defaultdict from socket import gethostname from hashlib import md5 @@ -33,7 +34,8 @@ from gluster.swift.common.exceptions import AlreadyExistsAsFile, \ AlreadyExistsAsDir from swift.common.utils import TRUE_VALUES, ThreadPool, config_true_value from swift.common.exceptions import DiskFileNotExist, DiskFileError, \ - DiskFileNoSpace, DiskFileDeviceUnavailable, DiskFileNotOpen + DiskFileNoSpace, DiskFileDeviceUnavailable, DiskFileNotOpen, \ + DiskFileExpired from swift.common.swob import multi_range_iterator from gluster.swift.common.exceptions import GlusterFileSystemOSError @@ -701,6 +703,7 @@ class DiskFile(object): the object representation does not exist. :raises DiskFileNotExist: if the object does not exist + :raises DiskFileExpired: if the object has expired :returns: itself for use as a context manager """ # Writes are always performed to a temporary file @@ -731,11 +734,28 @@ class DiskFile(object): obj_size = 0 self._fd = -1 else: + if self._is_object_expired(self._metadata): + raise DiskFileExpired(metadata=self._metadata) self._fd = fd self._obj_size = obj_size return self + def _is_object_expired(self, metadata): + try: + x_delete_at = int(metadata['X-Delete-At']) + except KeyError: + pass + except ValueError: + # x-delete-at key is present but not an integer. + # TODO: Openstack Swift "quarrantines" the object. + # We just let it pass + pass + else: + if x_delete_at <= time.time(): + return True + return False + def _filter_metadata(self): if X_TYPE in self._metadata: self._metadata.pop(X_TYPE) diff --git a/gluster/swift/obj/server.py b/gluster/swift/obj/server.py index 3cdd3c0..8bc080a 100644 --- a/gluster/swift/obj/server.py +++ b/gluster/swift/obj/server.py @@ -28,6 +28,14 @@ from swift.obj import server from gluster.swift.obj.diskfile import OnDiskManager +import os +from swift.common.exceptions import ConnectionTimeout +from swift.common.bufferedhttp import http_connect +from eventlet import Timeout +from swift.common.http import is_success +from gluster.swift.common.ring import Ring +from swift import gettext_ as _ + class ObjectController(server.ObjectController): """ @@ -44,16 +52,10 @@ class ObjectController(server.ObjectController): :param conf: WSGI configuration parameter """ - # FIXME: Gluster currently does not support x-delete-at, as there is - # no mechanism in GlusterFS itself to expire an object, or an external - # process that will cull expired objects. - try: - self.allowed_headers.remove('x-delete-at') - except KeyError: - pass # Common on-disk hierarchy shared across account, container and object # servers. self._ondisk_mgr = OnDiskManager(conf, self.logger) + self.swift_dir = conf.get('swift_dir', '/etc/swift') def get_diskfile(self, device, partition, account, container, obj, **kwargs): @@ -77,12 +79,90 @@ class ObjectController(server.ObjectController): """ return - def delete_at_update(self, *args, **kwargs): - """ - Update the expiring objects container when objects are updated. + def get_object_ring(self): + if hasattr(self, 'object_ring'): + if not self.object_ring: + self.object_ring = Ring(self.swift_dir, ring_name='object') + else: + self.object_ring = Ring(self.swift_dir, ring_name='object') + return self.object_ring - FIXME: Gluster currently does not support delete_at headers. + def async_update(self, op, account, container, obj, host, partition, + contdevice, headers_out, objdevice): """ + In Openstack Swift, this method is called by: + * container_update (a no-op in gluster-swift) + * delete_at_update (to PUT objects into .expiring_objects account) + + The Swift's version of async_update only sends the request to + container-server to PUT the object. The container-server calls + container_update method which makes an entry for the object in it's + database. No actual object is created on disk. + + But in gluster-swift container_update is a no-op, so we'll + have to PUT an actual object. We override async_update to create a + container first and then the corresponding "tracker object" which + tracks expired objects scheduled for deletion. + """ + + headers_out['user-agent'] = 'obj-server %s' % os.getpid() + if all([host, partition, contdevice]): + # PUT the container. Send request directly to container-server + container_path = '/%s/%s' % (account, container) + try: + with ConnectionTimeout(self.conn_timeout): + ip, port = host.rsplit(':', 1) + conn = http_connect(ip, port, contdevice, partition, op, + container_path, headers_out) + with Timeout(self.node_timeout): + response = conn.getresponse() + response.read() + if not is_success(response.status): + self.logger.error(_( + 'async_update : ' + 'ERROR Container update failed :%(status)d ' + 'response from %(ip)s:%(port)s/%(dev)s'), + {'status': response.status, 'ip': ip, 'port': port, + 'dev': contdevice}) + return + except (Exception, Timeout): + self.logger.exception(_( + 'async_update : ' + 'ERROR Container update failed :%(ip)s:%(port)s/%(dev)s'), + {'ip': ip, 'port': port, 'dev': contdevice}) + + # PUT the tracker object. Send request directly to object-server + object_path = '/%s/%s/%s' % (account, container, obj) + headers_out['Content-Length'] = 0 + headers_out['Content-Type'] = 'text/plain' + try: + with ConnectionTimeout(self.conn_timeout): + # FIXME: Assuming that get_nodes returns single node + part, nodes = self.get_object_ring().get_nodes(account, + container, + obj) + ip = nodes[0]['ip'] + port = nodes[0]['port'] + objdevice = nodes[0]['device'] + conn = http_connect(ip, port, objdevice, partition, op, + object_path, headers_out) + with Timeout(self.node_timeout): + response = conn.getresponse() + response.read() + if is_success(response.status): + return + else: + self.logger.error(_( + 'async_update : ' + 'ERROR Object PUT failed : %(status)d ' + 'response from %(ip)s:%(port)s/%(dev)s'), + {'status': response.status, 'ip': ip, 'port': port, + 'dev': objdevice}) + except (Exception, Timeout): + self.logger.exception(_( + 'async_update : ' + 'ERROR Object PUT failed :%(ip)s:%(port)s/%(dev)s'), + {'ip': ip, 'port': port, 'dev': objdevice}) return @public diff --git a/test/functional/gluster_swift_tests.py b/test/functional/gluster_swift_tests.py index 0a721b6..2768f9d 100644 --- a/test/functional/gluster_swift_tests.py +++ b/test/functional/gluster_swift_tests.py @@ -59,6 +59,10 @@ class TestFile(Base): self.assertEquals(data,data_read) def testInvalidHeadersPUT(self): + #TODO: Although we now support x-delete-at and x-delete-after, + #retained this test case as we may add some other header to + #unsupported list in future + raise SkipTest() file = self.env.container.file(Utils.create_name()) self.assertRaises(ResponseError, file.write_random, @@ -72,6 +76,10 @@ class TestFile(Base): self.assert_status(400) def testInvalidHeadersPOST(self): + #TODO: Although we now support x-delete-at and x-delete-after, + #retained this test case as we may add some other header to + #unsupported list in future + raise SkipTest() file = self.env.container.file(Utils.create_name()) file.write_random(self.env.file_size) headers = file.make_headers(cfg={}) diff --git a/test/unit/common/test_constraints.py b/test/unit/common/test_constraints.py index 180721c..6c78d75 100644 --- a/test/unit/common/test_constraints.py +++ b/test/unit/common/test_constraints.py @@ -81,6 +81,10 @@ class TestConstraints(unittest.TestCase): self.assertEqual(cnt.validate_headers(req), '') req.headers = ['x-some-header'] self.assertEqual(cnt.validate_headers(req), '') + #TODO: Although we now support x-delete-at and x-delete-after, + #retained this test case as we may add some other header to + #unsupported list in future + raise SkipTest req.headers = ['x-delete-at', 'x-some-header'] self.assertNotEqual(cnt.validate_headers(req), '') req.headers = ['x-delete-after', 'x-some-header'] @@ -96,6 +100,10 @@ class TestConstraints(unittest.TestCase): self.assertEqual(cnt.validate_headers(req), '') req.headers = ['x-some-header'] self.assertEqual(cnt.validate_headers(req), '') + #TODO: Although we now support x-delete-at and x-delete-after, + #retained this test case as we may add some other header to + #unsupported list in future + raise SkipTest req.headers = ['x-delete-at', 'x-some-header'] self.assertEqual(cnt.validate_headers(req), '') req.headers = ['x-delete-after', 'x-some-header'] @@ -115,6 +123,10 @@ class TestConstraints(unittest.TestCase): self.assertTrue(1, mock_check_metadata.call_count) req.headers = ['x-some-header'] self.assertEqual(cnt.gluster_check_metadata(req, 'object', POST=False), None) + #TODO: Although we now support x-delete-at and x-delete-after, + #retained this test case as we may add some other header to + #unsupported list in future + raise SkipTest req.headers = ['x-delete-at', 'x-some-header'] self.assertNotEqual(cnt.gluster_check_metadata(req, 'object', POST=False), None) req.headers = ['x-delete-after', 'x-some-header'] @@ -135,5 +147,9 @@ class TestConstraints(unittest.TestCase): req = Mock() req.headers = [] self.assertTrue(cnt.gluster_check_object_creation(req, 'dir/.')) + #TODO: Although we now support x-delete-at and x-delete-after, + #retained this test case as we may add some other header to + #unsupported list in future + raise SkipTest req.headers = ['x-delete-at'] self.assertTrue(cnt.gluster_check_object_creation(req, 'dir/z')) diff --git a/test/unit/obj/test_expirer.py b/test/unit/obj/test_expirer.py new file mode 100644 index 0000000..4329eef --- /dev/null +++ b/test/unit/obj/test_expirer.py @@ -0,0 +1,701 @@ +# Copyright (c) 2011 OpenStack Foundation +# +# 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 urllib +from time import time +from unittest import main, TestCase +from test.unit import FakeLogger +from copy import deepcopy + +import mock + +from swift.common import internal_client +from swift.obj import expirer + + +def not_random(): + return 0.5 + + +last_not_sleep = 0 + + +def not_sleep(seconds): + global last_not_sleep + last_not_sleep = seconds + + +class TestObjectExpirer(TestCase): + maxDiff = None + + def setUp(self): + global not_sleep + + self.old_loadapp = internal_client.loadapp + self.old_sleep = internal_client.sleep + + internal_client.loadapp = lambda x: None + internal_client.sleep = not_sleep + + def teardown(self): + internal_client.sleep = self.old_sleep + internal_client.loadapp = self.loadapp + + def test_get_process_values_from_kwargs(self): + x = expirer.ObjectExpirer({}) + vals = { + 'processes': 5, + 'process': 1, + } + self.assertEqual((5, 1), x.get_process_values(vals)) + + def test_get_process_values_from_config(self): + vals = { + 'processes': 5, + 'process': 1, + } + x = expirer.ObjectExpirer(vals) + self.assertEqual((5, 1), x.get_process_values({})) + + def test_get_process_values_negative_process(self): + vals = { + 'processes': 5, + 'process': -1, + } + # from config + x = expirer.ObjectExpirer(vals) + self.assertRaises(ValueError, x.get_process_values, {}) + # from kwargs + x = expirer.ObjectExpirer({}) + self.assertRaises(ValueError, x.get_process_values, vals) + + def test_get_process_values_negative_processes(self): + vals = { + 'processes': -5, + 'process': 1, + } + # from config + x = expirer.ObjectExpirer(vals) + self.assertRaises(ValueError, x.get_process_values, {}) + # from kwargs + x = expirer.ObjectExpirer({}) + self.assertRaises(ValueError, x.get_process_values, vals) + + def test_get_process_values_process_greater_than_processes(self): + vals = { + 'processes': 5, + 'process': 7, + } + # from config + x = expirer.ObjectExpirer(vals) + self.assertRaises(ValueError, x.get_process_values, {}) + # from kwargs + x = expirer.ObjectExpirer({}) + self.assertRaises(ValueError, x.get_process_values, vals) + + def test_init_concurrency_too_small(self): + conf = { + 'concurrency': 0, + } + self.assertRaises(ValueError, expirer.ObjectExpirer, conf) + conf = { + 'concurrency': -1, + } + self.assertRaises(ValueError, expirer.ObjectExpirer, conf) + + def test_process_based_concurrency(self): + + class ObjectExpirer(expirer.ObjectExpirer): + + def __init__(self, conf): + super(ObjectExpirer, self).__init__(conf) + self.processes = 3 + self.deleted_objects = {} + + def delete_object(self, actual_obj, timestamp, container, obj): + if container not in self.deleted_objects: + self.deleted_objects[container] = set() + self.deleted_objects[container].add(obj) + + class InternalClient(object): + + def __init__(self, containers): + self.containers = containers + + def get_account_info(self, *a, **kw): + return len(self.containers.keys()), \ + sum([len(self.containers[x]) for x in self.containers]) + + def iter_containers(self, *a, **kw): + return [{'name': x} for x in self.containers.keys()] + + def iter_objects(self, account, container): + return [{'name': x} for x in self.containers[container]] + + def delete_container(*a, **kw): + pass + + containers = { + 0: set('1-one 2-two 3-three'.split()), + 1: set('2-two 3-three 4-four'.split()), + 2: set('5-five 6-six'.split()), + 3: set('7-seven'.split()), + } + x = ObjectExpirer({}) + x.swift = InternalClient(containers) + + deleted_objects = {} + for i in xrange(3): + x.process = i + x.run_once() + self.assertNotEqual(deleted_objects, x.deleted_objects) + deleted_objects = deepcopy(x.deleted_objects) + self.assertEqual(containers, deleted_objects) + + def test_delete_object(self): + class InternalClient(object): + def __init__(self, test, account, container, obj): + self.test = test + self.account = account + self.container = container + self.obj = obj + self.delete_object_called = False + + def delete_object(self, account, container, obj): + self.test.assertEqual(self.account, account) + self.test.assertEqual(self.container, container) + self.test.assertEqual(self.obj, obj) + self.delete_object_called = True + + class DeleteActualObject(object): + def __init__(self, test, actual_obj, timestamp): + self.test = test + self.actual_obj = actual_obj + self.timestamp = timestamp + self.called = False + + def __call__(self, actual_obj, timestamp): + self.test.assertEqual(self.actual_obj, actual_obj) + self.test.assertEqual(self.timestamp, timestamp) + self.called = True + + container = 'container' + obj = 'obj' + actual_obj = 'actual_obj' + timestamp = 'timestamp' + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.swift = \ + InternalClient(self, x.expiring_objects_account, container, obj) + x.delete_actual_object = \ + DeleteActualObject(self, actual_obj, timestamp) + + x.delete_object(actual_obj, timestamp, container, obj) + self.assertTrue(x.swift.delete_object_called) + self.assertTrue(x.delete_actual_object.called) + + def test_report(self): + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + + x.report() + self.assertEqual(x.logger.log_dict['info'], []) + + x.logger._clear() + x.report(final=True) + self.assertTrue('completed' in x.logger.log_dict['info'][-1][0][0], + x.logger.log_dict['info']) + self.assertTrue('so far' not in x.logger.log_dict['info'][-1][0][0], + x.logger.log_dict['info']) + + x.logger._clear() + x.report_last_time = time() - x.report_interval + x.report() + self.assertTrue('completed' not in x.logger.log_dict['info'][-1][0][0], + x.logger.log_dict['info']) + self.assertTrue('so far' in x.logger.log_dict['info'][-1][0][0], + x.logger.log_dict['info']) + + def test_run_once_nothing_to_do(self): + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.swift = 'throw error because a string does not have needed methods' + x.run_once() + self.assertEqual(x.logger.log_dict['exception'], + [(("Unhandled exception",), {}, + "'str' object has no attribute " + "'get_account_info'")]) + + def test_run_once_calls_report(self): + class InternalClient(object): + def get_account_info(*a, **kw): + return 1, 2 + + def iter_containers(*a, **kw): + return [] + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.swift = InternalClient() + x.run_once() + self.assertEqual( + x.logger.log_dict['info'], + [(('Pass beginning; 1 possible containers; ' + '2 possible objects',), {}), + (('Pass completed in 0s; 0 objects expired',), {})]) + + def test_container_timestamp_break(self): + class InternalClient(object): + def __init__(self, containers): + self.containers = containers + + def get_account_info(*a, **kw): + return 1, 2 + + def iter_containers(self, *a, **kw): + return self.containers + + def iter_objects(*a, **kw): + raise Exception('This should not have been called') + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.swift = InternalClient([{'name': str(int(time() + 86400))}]) + x.run_once() + for exccall in x.logger.log_dict['exception']: + self.assertTrue( + 'This should not have been called' not in exccall[0][0]) + self.assertEqual( + x.logger.log_dict['info'], + [(('Pass beginning; 1 possible containers; ' + '2 possible objects',), {}), + (('Pass completed in 0s; 0 objects expired',), {})]) + + # Reverse test to be sure it still would blow up the way expected. + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.swift = InternalClient([{'name': str(int(time() - 86400))}]) + x.run_once() + self.assertEqual( + x.logger.log_dict['exception'], + [(('Unhandled exception',), {}, + str(Exception('This should not have been called')))]) + + def test_object_timestamp_break(self): + class InternalClient(object): + def __init__(self, containers, objects): + self.containers = containers + self.objects = objects + + def get_account_info(*a, **kw): + return 1, 2 + + def iter_containers(self, *a, **kw): + return self.containers + + def delete_container(*a, **kw): + pass + + def iter_objects(self, *a, **kw): + return self.objects + + def should_not_be_called(*a, **kw): + raise Exception('This should not have been called') + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.swift = InternalClient( + [{'name': str(int(time() - 86400))}], + [{'name': '%d-actual-obj' % int(time() + 86400)}]) + x.run_once() + for exccall in x.logger.log_dict['exception']: + self.assertTrue( + 'This should not have been called' not in exccall[0][0]) + self.assertEqual( + x.logger.log_dict['info'], + [(('Pass beginning; 1 possible containers; ' + '2 possible objects',), {}), + (('Pass completed in 0s; 0 objects expired',), {})]) + + # Reverse test to be sure it still would blow up the way expected. + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + ts = int(time() - 86400) + x.swift = InternalClient( + [{'name': str(int(time() - 86400))}], + [{'name': '%d-actual-obj' % ts}]) + x.delete_actual_object = should_not_be_called + x.run_once() + excswhiledeleting = [] + for exccall in x.logger.log_dict['exception']: + if exccall[0][0].startswith('Exception while deleting '): + excswhiledeleting.append(exccall[0][0]) + self.assertEqual( + excswhiledeleting, + ['Exception while deleting object %d %d-actual-obj ' + 'This should not have been called' % (ts, ts)]) + + def test_failed_delete_keeps_entry(self): + class InternalClient(object): + def __init__(self, containers, objects): + self.containers = containers + self.objects = objects + + def get_account_info(*a, **kw): + return 1, 2 + + def iter_containers(self, *a, **kw): + return self.containers + + def delete_container(*a, **kw): + pass + + def delete_object(*a, **kw): + raise Exception('This should not have been called') + + def iter_objects(self, *a, **kw): + return self.objects + + def deliberately_blow_up(actual_obj, timestamp): + raise Exception('failed to delete actual object') + + def should_not_get_called(container, obj): + raise Exception('This should not have been called') + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.iter_containers = lambda: [str(int(time() - 86400))] + ts = int(time() - 86400) + x.delete_actual_object = deliberately_blow_up + x.swift = InternalClient( + [{'name': str(int(time() - 86400))}], + [{'name': '%d-actual-obj' % ts}]) + x.run_once() + excswhiledeleting = [] + for exccall in x.logger.log_dict['exception']: + if exccall[0][0].startswith('Exception while deleting '): + excswhiledeleting.append(exccall[0][0]) + self.assertEqual( + excswhiledeleting, + ['Exception while deleting object %d %d-actual-obj ' + 'failed to delete actual object' % (ts, ts)]) + self.assertEqual( + x.logger.log_dict['info'], + [(('Pass beginning; 1 possible containers; ' + '2 possible objects',), {}), + (('Pass completed in 0s; 0 objects expired',), {})]) + + # Reverse test to be sure it still would blow up the way expected. + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + ts = int(time() - 86400) + x.delete_actual_object = lambda o, t: None + x.swift = InternalClient( + [{'name': str(int(time() - 86400))}], + [{'name': '%d-actual-obj' % ts}]) + x.run_once() + excswhiledeleting = [] + for exccall in x.logger.log_dict['exception']: + if exccall[0][0].startswith('Exception while deleting '): + excswhiledeleting.append(exccall[0][0]) + self.assertEqual( + excswhiledeleting, + ['Exception while deleting object %d %d-actual-obj This should ' + 'not have been called' % (ts, ts)]) + + def test_success_gets_counted(self): + class InternalClient(object): + def __init__(self, containers, objects): + self.containers = containers + self.objects = objects + + def get_account_info(*a, **kw): + return 1, 2 + + def iter_containers(self, *a, **kw): + return self.containers + + def delete_container(*a, **kw): + pass + + def delete_object(*a, **kw): + pass + + def iter_objects(self, *a, **kw): + return self.objects + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.delete_actual_object = lambda o, t: None + self.assertEqual(x.report_objects, 0) + x.swift = InternalClient( + [{'name': str(int(time() - 86400))}], + [{'name': '%d-actual-obj' % int(time() - 86400)}]) + x.run_once() + self.assertEqual(x.report_objects, 1) + self.assertEqual( + x.logger.log_dict['info'], + [(('Pass beginning; 1 possible containers; ' + '2 possible objects',), {}), + (('Pass completed in 0s; 1 objects expired',), {})]) + + def test_delete_actual_object_does_not_get_unicode(self): + class InternalClient(object): + def __init__(self, containers, objects): + self.containers = containers + self.objects = objects + + def get_account_info(*a, **kw): + return 1, 2 + + def iter_containers(self, *a, **kw): + return self.containers + + def delete_container(*a, **kw): + pass + + def delete_object(*a, **kw): + pass + + def iter_objects(self, *a, **kw): + return self.objects + + got_unicode = [False] + + def delete_actual_object_test_for_unicode(actual_obj, timestamp): + if isinstance(actual_obj, unicode): + got_unicode[0] = True + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + x.delete_actual_object = delete_actual_object_test_for_unicode + self.assertEqual(x.report_objects, 0) + x.swift = InternalClient( + [{'name': str(int(time() - 86400))}], + [{'name': u'%d-actual-obj' % int(time() - 86400)}]) + x.run_once() + self.assertEqual(x.report_objects, 1) + self.assertEqual( + x.logger.log_dict['info'], + [(('Pass beginning; 1 possible containers; ' + '2 possible objects',), {}), + (('Pass completed in 0s; 1 objects expired',), {})]) + self.assertFalse(got_unicode[0]) + + def test_failed_delete_continues_on(self): + class InternalClient(object): + def __init__(self, containers, objects): + self.containers = containers + self.objects = objects + + def get_account_info(*a, **kw): + return 1, 2 + + def iter_containers(self, *a, **kw): + return self.containers + + def delete_container(*a, **kw): + raise Exception('failed to delete container') + + def delete_object(*a, **kw): + pass + + def iter_objects(self, *a, **kw): + return self.objects + + def fail_delete_actual_object(actual_obj, timestamp): + raise Exception('failed to delete actual object') + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + + cts = int(time() - 86400) + ots = int(time() - 86400) + + containers = [ + {'name': str(cts)}, + {'name': str(cts + 1)}, + ] + + objects = [ + {'name': '%d-actual-obj' % ots}, + {'name': '%d-next-obj' % ots} + ] + + x.swift = InternalClient(containers, objects) + x.delete_actual_object = fail_delete_actual_object + x.run_once() + excswhiledeleting = [] + for exccall in x.logger.log_dict['exception']: + if exccall[0][0].startswith('Exception while deleting '): + excswhiledeleting.append(exccall[0][0]) + self.assertEqual(sorted(excswhiledeleting), sorted([ + 'Exception while deleting object %d %d-actual-obj failed to ' + 'delete actual object' % (cts, ots), + 'Exception while deleting object %d %d-next-obj failed to ' + 'delete actual object' % (cts, ots), + 'Exception while deleting object %d %d-actual-obj failed to ' + 'delete actual object' % (cts + 1, ots), + 'Exception while deleting object %d %d-next-obj failed to ' + 'delete actual object' % (cts + 1, ots), + 'Exception while deleting container %d failed to delete ' + 'container' % (cts,), + 'Exception while deleting container %d failed to delete ' + 'container' % (cts + 1,)])) + self.assertEqual( + x.logger.log_dict['info'], + [(('Pass beginning; 1 possible containers; ' + '2 possible objects',), {}), + (('Pass completed in 0s; 0 objects expired',), {})]) + + def test_run_forever_initial_sleep_random(self): + global last_not_sleep + + def raise_system_exit(): + raise SystemExit('test_run_forever') + + interval = 1234 + x = expirer.ObjectExpirer({'__file__': 'unit_test', + 'interval': interval}) + orig_random = expirer.random + orig_sleep = expirer.sleep + try: + expirer.random = not_random + expirer.sleep = not_sleep + x.run_once = raise_system_exit + x.run_forever() + except SystemExit as err: + pass + finally: + expirer.random = orig_random + expirer.sleep = orig_sleep + self.assertEqual(str(err), 'test_run_forever') + self.assertEqual(last_not_sleep, 0.5 * interval) + + def test_run_forever_catches_usual_exceptions(self): + raises = [0] + + def raise_exceptions(): + raises[0] += 1 + if raises[0] < 2: + raise Exception('exception %d' % raises[0]) + raise SystemExit('exiting exception %d' % raises[0]) + + x = expirer.ObjectExpirer({}) + x.logger = FakeLogger() + orig_sleep = expirer.sleep + try: + expirer.sleep = not_sleep + x.run_once = raise_exceptions + x.run_forever() + except SystemExit as err: + pass + finally: + expirer.sleep = orig_sleep + self.assertEqual(str(err), 'exiting exception 2') + self.assertEqual(x.logger.log_dict['exception'], + [(('Unhandled exception',), {}, + 'exception 1')]) + + def test_delete_actual_object(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 x: fake_app + + x = expirer.ObjectExpirer({}) + ts = '1234' + x.delete_actual_object('/path/to/object', ts) + self.assertEqual(got_env[0]['HTTP_X_IF_DELETE_AT'], ts) + + def test_delete_actual_object_nourlquoting(self): + # delete_actual_object should not do its own url quoting because + # internal client's make_request handles that. + 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 x: fake_app + + x = expirer.ObjectExpirer({}) + ts = '1234' + x.delete_actual_object('/path/to/object name', ts) + self.assertEqual(got_env[0]['HTTP_X_IF_DELETE_AT'], ts) + self.assertEqual(got_env[0]['PATH_INFO'], '/v1/path/to/object name') + + def test_delete_actual_object_handles_404(self): + + def fake_app(env, start_response): + start_response('404 Not Found', [('Content-Length', '0')]) + return [] + + internal_client.loadapp = lambda x: fake_app + + x = expirer.ObjectExpirer({}) + x.delete_actual_object('/path/to/object', '1234') + + def test_delete_actual_object_handles_412(self): + + def fake_app(env, start_response): + start_response('412 Precondition Failed', + [('Content-Length', '0')]) + return [] + + internal_client.loadapp = lambda x: fake_app + + x = expirer.ObjectExpirer({}) + x.delete_actual_object('/path/to/object', '1234') + + def test_delete_actual_object_does_not_handle_odd_stuff(self): + + def fake_app(env, start_response): + start_response( + '503 Internal Server Error', + [('Content-Length', '0')]) + return [] + + internal_client.loadapp = lambda x: fake_app + + x = expirer.ObjectExpirer({}) + exc = None + try: + x.delete_actual_object('/path/to/object', '1234') + except Exception as err: + exc = err + finally: + pass + self.assertEqual(503, exc.resp.status_int) + + def test_delete_actual_object_quotes(self): + name = 'this name should get quoted' + timestamp = '1366063156.863045' + x = expirer.ObjectExpirer({}) + x.swift.make_request = mock.MagicMock() + x.delete_actual_object(name, timestamp) + x.swift.make_request.assert_called_once() + self.assertEqual(x.swift.make_request.call_args[0][1], + '/v1/' + urllib.quote(name)) + + +if __name__ == '__main__': + main() diff --git a/test/unit/proxy/test_server.py b/test/unit/proxy/test_server.py index 4086a32..0cb2278 100644 --- a/test/unit/proxy/test_server.py +++ b/test/unit/proxy/test_server.py @@ -3461,7 +3461,6 @@ class TestObjectController(unittest.TestCase): self.assert_(called[0]) def test_POST_converts_delete_after_to_delete_at(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): controller = proxy_server.ObjectController(self.app, 'account', 'container', 'object') @@ -3498,7 +3497,6 @@ class TestObjectController(unittest.TestCase): time.time = orig_time def test_POST_non_int_delete_after(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): controller = proxy_server.ObjectController(self.app, 'account', 'container', 'object') @@ -3513,7 +3511,6 @@ class TestObjectController(unittest.TestCase): self.assertTrue('Non-integer X-Delete-After' in res.body) def test_POST_negative_delete_after(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): controller = proxy_server.ObjectController(self.app, 'account', 'container', 'object') @@ -3528,7 +3525,6 @@ class TestObjectController(unittest.TestCase): self.assertTrue('X-Delete-At in past' in res.body) def test_POST_delete_at(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): given_headers = {} @@ -3573,7 +3569,6 @@ class TestObjectController(unittest.TestCase): self.assertTrue('X-Delete-At in past' in resp.body) def test_PUT_converts_delete_after_to_delete_at(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): controller = proxy_server.ObjectController(self.app, 'account', 'container', 'object') @@ -3596,7 +3591,6 @@ class TestObjectController(unittest.TestCase): time.time = orig_time def test_PUT_non_int_delete_after(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): controller = proxy_server.ObjectController(self.app, 'account', 'container', 'object') @@ -3612,7 +3606,6 @@ class TestObjectController(unittest.TestCase): self.assertTrue('Non-integer X-Delete-After' in res.body) def test_PUT_negative_delete_after(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): controller = proxy_server.ObjectController(self.app, 'account', 'container', 'object') @@ -3628,7 +3621,6 @@ class TestObjectController(unittest.TestCase): self.assertTrue('X-Delete-At in past' in res.body) def test_PUT_delete_at(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") with save_globals(): given_headers = {} @@ -4023,7 +4015,6 @@ class TestObjectController(unittest.TestCase): @mock.patch('time.time', new=lambda: STATIC_TIME) def test_PUT_x_delete_at_with_fewer_container_replicas(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") self.app.container_ring.set_replicas(2) delete_at_timestamp = int(time.time()) + 100000 @@ -4059,7 +4050,6 @@ class TestObjectController(unittest.TestCase): @mock.patch('time.time', new=lambda: STATIC_TIME) def test_PUT_x_delete_at_with_more_container_replicas(self): - raise SkipTest("X-Delete-At and X-Delete-After are not supported") self.app.container_ring.set_replicas(4) self.app.expiring_objects_account = 'expires' self.app.expiring_objects_container_divisor = 60