Merge lp:~vila/uci-engine/delete-containers into lp:uci-engine

Proposed by Vincent Ladeuil
Status: Superseded
Proposed branch: lp:~vila/uci-engine/delete-containers
Merge into: lp:uci-engine
Diff against target: 442 lines (+165/-97)
6 files modified
britney_proxy/britney/process_results.py (+1/-5)
ci-utils/ci_utils/data_store.py (+78/-49)
ci-utils/ci_utils/testing/fixtures.py (+6/-1)
ci-utils/ci_utils/tests/test_data_store.py (+66/-20)
ci-utils/ci_utils/tests/test_fixtures.py (+4/-4)
tests/test_data_store.py (+10/-18)
To merge this branch: bzr merge lp:~vila/uci-engine/delete-containers
Reviewer Review Type Date Requested Status
Evan (community) Needs Fixing
Review via email: mp+242315@code.launchpad.net

Commit message

Safely delete swift containers

Description of the change

This builds on top of https://code.launchpad.net/~vila/uci-engine/cleanup-data-store/+merge/242218 and extends the swift client retry policy to the delete container request.

This allows us to reliably delete swift containers whne they become useless (in-production tests, uci-britney, test themselves, etc).

I've tuned the overall timeout to ~5 minutes based on previous experimentations which showed real life inconsistency windows going up to 2 minutes.

To post a comment you must log in.
909. By Vincent Ladeuil

One more test to ensure we fail after all retries (with a test reproducing the bug).

Tested with: ./run-tests ^tests.test_style ^tests.test_data_store ^ci-utils.*test_fixtures ^ci-utils.*test_data_store

910. By Vincent Ladeuil

One more test for the retries=0 case to address review comment.

911. By Vincent Ladeuil

Fix pyflakes issue.

912. By Vincent Ladeuil

Fix pep8 issue.

Revision history for this message
Evan (ev) :
review: Needs Fixing

Unmerged revisions

912. By Vincent Ladeuil

Fix pep8 issue.

911. By Vincent Ladeuil

Fix pyflakes issue.

910. By Vincent Ladeuil

One more test for the retries=0 case to address review comment.

909. By Vincent Ladeuil

One more test to ensure we fail after all retries (with a test reproducing the bug).

Tested with: ./run-tests ^tests.test_style ^tests.test_data_store ^ci-utils.*test_fixtures ^ci-utils.*test_data_store

908. By Vincent Ladeuil

Extend swiftclient retry policy to container deletion with tests.

907. By Vincent Ladeuil

Meh, we want to test the real data store with a fake swift client, not the fake data store ;)

906. By Vincent Ladeuil

One less FIXME and a test plan.

905. By Vincent Ladeuil

Tested with ./run-tests ^tests.test_style ^tests.test_data_store befor submission.

(and some cosmetic changes)

904. By Vincent Ladeuil

This test pollutes the run output.

903. By Vincent Ladeuil

Log auth info (except for the password).

Rewrite exception in list_files.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'britney_proxy/britney/process_results.py'
--- britney_proxy/britney/process_results.py 2014-10-30 17:30:49 +0000
+++ britney_proxy/britney/process_results.py 2014-11-20 08:47:10 +0000
@@ -51,11 +51,7 @@
51# container. This won't scale but we'll revisit the layout when we get a better51# container. This won't scale but we'll revisit the layout when we get a better
52# understanding of the britney needs (and numbers !) -- vila 2014-10-0352# understanding of the britney needs (and numbers !) -- vila 2014-10-03
53class BritneyDataStore(data_store.DataStore):53class BritneyDataStore(data_store.DataStore):
5454 pass
55 def file_name(self, filename):
56 # Allow paths to be used as object names in the container by overriding
57 # the filtering in the base class.
58 return filename
5955
6056
61class BritneyResultHandler(object):57class BritneyResultHandler(object):
6258
=== modified file 'ci-utils/ci_utils/data_store.py'
--- ci-utils/ci_utils/data_store.py 2014-10-07 10:04:01 +0000
+++ ci-utils/ci_utils/data_store.py 2014-11-20 08:47:10 +0000
@@ -14,10 +14,10 @@
14# along with this program. If not, see <http://www.gnu.org/licenses/>.14# along with this program. If not, see <http://www.gnu.org/licenses/>.
1515
16import logging16import logging
17import os17import time
18
1819
19from swiftclient import client20from swiftclient import client
20from swiftclient.client import ClientException
2121
2222
23class DataStoreException(Exception):23class DataStoreException(Exception):
@@ -31,11 +31,26 @@
31class DataStore(object):31class DataStore(object):
32 """Store and retrieve files into a swift container."""32 """Store and retrieve files into a swift container."""
3333
34 def __init__(self, component, auth_config, public=False):34 def __init__(self, component, auth_config, public=False,
35 # FIXME: These should really be config options as all data
36 # stores will use the same but in the mean time, having them
37 # available here address the test needs -- vila 2014-11-19
38 starting_backoff=1, max_backoff=64, retries=10):
35 self.container_id = component39 self.container_id = component
36 self.auth_config = auth_config40 self.auth_config = auth_config
37 self.public = public41 self.public = public
38 self.client = None42 self.client = None
43 # The swiftclient requests are retried with increasing timeouts based
44 # on 'starting_backoff' and 'max_backoff'. For simplicity, we apply the
45 # same policy to delete_container() on 409 errors (Conflict: container
46 # not empty) which swiftclient doesn't take into account.
47 # FIXME: This implementation misses the random part in the timeout
48 # calculation -- vila 2014-11-20
49 self.starting_backoff = starting_backoff
50 self.max_backoff = max_backoff
51 # The 7 first backoff values will be: 1, 2, 4... 64 => 127secs, so 10
52 # retries will give us 5 minutes
53 self.retries = retries
3954
40 @staticmethod55 @staticmethod
41 def validate_auth_config(auth_config):56 def validate_auth_config(auth_config):
@@ -50,33 +65,45 @@
5065
51 if len(missing_fields) > 0:66 if len(missing_fields) > 0:
52 raise DataStoreException(67 raise DataStoreException(
53 "missing fields: {}".format(', '.join(missing_fields))68 "missing fields: {}".format(', '.join(missing_fields)))
54 )
5569
56 def ensure_swift_client(self):70 def ensure_swift_client(self):
71 """Ensures we have a valid swift client and a container.
72
73 This allows callers of the DataStore to call any part of the API
74 without worrying about creating the container.
75 """
57 if self.client is not None:76 if self.client is not None:
58 return self.client77 return self.client
5978
60 self.validate_auth_config(self.auth_config)79 self.validate_auth_config(self.auth_config)
61 config = self.auth_config80 config = self.auth_config
62 self.client = client.Connection(81 # All authentication data bar the password so we can log them.
63 authurl=config.get('auth_url'),82 auth = dict(authurl=config.get('auth_url'),
64 user=config.get('auth_user'),83 user=config.get('auth_user'),
65 key=config.get('auth_password'),84 os_options={'tenant_name': config.get('auth_tenant_name'),
66 os_options={'tenant_name': config.get('auth_tenant_name'),85 'region_name': config.get('auth_region')},
67 'region_name': config.get('auth_region')},86 auth_version=('/v1.' in config.get('auth_url') and '1.0'
68 auth_version=('/v1.' in config.get('auth_url') and '1.0' or '2.0'))87 or '2.0'))
88 self.client = client.Connection(key=config.get('auth_password'),
89 **auth)
69 try:90 try:
91 # Creating a container is idempotent and can be repeated
70 self._create_container(self.container_id)92 self._create_container(self.container_id)
71 except client.ClientException:93 except client.ClientException:
94 # This is the first connection attempt in the object life
72 logging.exception('Unable to connect to swift')95 logging.exception('Unable to connect to swift')
73 raise DataStoreException("Missing or invalid authentication info.")96 raise DataStoreException(
97 "Invalid authentication info: {}".format(auth))
74 self.change_visibility(public=self.public)98 self.change_visibility(public=self.public)
75 self.container_url = "{}/{}".format(self.client.url, self.container_id)99 self.container_url = "{}/{}".format(self.client.url, self.container_id)
76100
77 def list_files(self):101 def list_files(self):
78 self.ensure_swift_client()102 self.ensure_swift_client()
79 _, objects = self.client.get_container(self.container_id)103 try:
104 _, objects = self.client.get_container(self.container_id)
105 except client.ClientException as e:
106 raise DataStoreException("Failed to get container: {}".format(e))
80 return [x['name'] for x in objects]107 return [x['name'] for x in objects]
81108
82 def put_file(self, filename, contents, content_type=None):109 def put_file(self, filename, contents, content_type=None):
@@ -86,10 +113,9 @@
86 self.client.put_object(self.container_id, obj=name,113 self.client.put_object(self.container_id, obj=name,
87 contents=contents,114 contents=contents,
88 content_type=content_type)115 content_type=content_type)
89 except ClientException as e:116 except client.ClientException as e:
90 raise DataStoreException(117 raise DataStoreException(
91 "Failed to upload file: {}, Error: {}".format(filename, e)118 "Failed to upload file: {}, Error: {}".format(filename, e))
92 )
93119
94 return self.file_path(filename)120 return self.file_path(filename)
95121
@@ -100,10 +126,9 @@
100 contents = None126 contents = None
101 try:127 try:
102 hdr, contents = self.client.get_object(self.container_id, obj=name)128 hdr, contents = self.client.get_object(self.container_id, obj=name)
103 except ClientException as e:129 except client.ClientException as e:
104 raise DataStoreException(130 raise DataStoreException(
105 "Failed to get file: {}, Error: {}".format(filename, e)131 "Failed to get file: {}, Error: {}".format(filename, e))
106 )
107 return contents132 return contents
108133
109 def change_visibility(self, public=False):134 def change_visibility(self, public=False):
@@ -121,40 +146,44 @@
121 name = self.file_name(filename)146 name = self.file_name(filename)
122 try:147 try:
123 self.client.delete_object(self.container_id, obj=name)148 self.client.delete_object(self.container_id, obj=name)
124 except ClientException as e:149 except client.ClientException as e:
125 raise DataStoreException(150 raise DataStoreException(
126 "Failed to delete file: {}, Error: {}".format(filename, e)151 "Failed to delete file: {}, Error: {}".format(filename, e))
127 )
128152
129 def clear(self):153 def clear(self):
154 files = self.list_files()
155 for fname in files:
156 self.delete_file(fname)
157
158 def delete(self, recursive=False):
130 self.ensure_swift_client()159 self.ensure_swift_client()
160 if recursive:
161 self.clear()
131 try:162 try:
132 files = self.client.get_container(self.container_id)[1]163 attempts = 0
133164 backoff = self.starting_backoff
134 for f in files:165 while attempts < self.retries:
135 try:166 try:
136 self.client.delete_object(self.container_id,167 self.client.delete_container(self.container_id)
137 obj=f['name'])168 break
138 except ClientException as e:169 except client.ClientException as e:
139 raise DataStoreException(170 if e.http_status == 409:
140 "Failed to delete file: {}".format(e)171 # The container is not empty yet
141 )172 pass
142 except ClientException as e:173 elif attempts > 0 and e.http_status == 404:
143 raise DataStoreException(174 # The deletion succeeded while we slept, we're done,
144 "Failed to get container: {}".format(e)175 # the container is gone
145 )176 break
146177 else:
147 def delete(self, recursive=False):178 msg = 'Failed to delete container (attempts: {}): {}'
148 self.ensure_swift_client()179 raise DataStoreException(msg.format(attempts, e))
149 try:180 # Take a nap before retrying
150 if recursive:181 time.sleep(backoff)
151 self.clear()182 backoff = min(backoff * 2, self.max_backoff)
152183 attempts += 1
153 self.client.delete_container(self.container_id)184 except client.ClientException as e:
154 except ClientException as e:185 raise DataStoreException(
155 raise DataStoreException(186 "Failed to delete container: {}".format(e))
156 "Failed to delete container: {}".format(e)
157 )
158187
159 def file_name(self, filename):188 def file_name(self, filename):
160 """Returns the file name that is used inside the data store.189 """Returns the file name that is used inside the data store.
@@ -162,7 +191,7 @@
162 :param filename: The file name used to build the object name inside the191 :param filename: The file name used to build the object name inside the
163 container.192 container.
164 """193 """
165 return os.path.basename(filename)194 return filename
166195
167 def file_path(self, filename):196 def file_path(self, filename):
168 name = self.file_name(filename)197 name = self.file_name(filename)
169198
=== modified file 'ci-utils/ci_utils/testing/fixtures.py'
--- ci-utils/ci_utils/testing/fixtures.py 2014-10-29 11:42:52 +0000
+++ ci-utils/ci_utils/testing/fixtures.py 2014-11-20 08:47:10 +0000
@@ -89,7 +89,12 @@
89 def delete(self, recursive=False):89 def delete(self, recursive=False):
90 if recursive:90 if recursive:
91 self.clear()91 self.clear()
92 os.rmdir(self.container_id)92 try:
93 os.rmdir(self.container_id)
94 except OSError as e:
95 if e.errno == errno.ENOTEMPTY:
96 raise data_store.DataStoreException(
97 "Failed to delete container: {}".format(e))
9398
94 def file_path(self, filename):99 def file_path(self, filename):
95 return '{}{}/{}'.format('file://', self.container_id, filename)100 return '{}{}/{}'.format('file://', self.container_id, filename)
96101
=== modified file 'ci-utils/ci_utils/tests/test_data_store.py'
--- ci-utils/ci_utils/tests/test_data_store.py 2014-10-07 10:04:01 +0000
+++ ci-utils/ci_utils/tests/test_data_store.py 2014-11-20 08:47:10 +0000
@@ -13,9 +13,14 @@
13# You should have received a copy of the GNU Affero General Public License13# You should have received a copy of the GNU Affero General Public License
14# along with this program. If not, see <http://www.gnu.org/licenses/>.14# along with this program. If not, see <http://www.gnu.org/licenses/>.
1515
16import os
16import unittest17import unittest
1718
1819
20from ucitests import fixtures
21from swiftclient import client
22
23
19from ci_utils import data_store24from ci_utils import data_store
2025
2126
@@ -35,23 +40,64 @@
35 data_store.DataStore.validate_auth_config({})40 data_store.DataStore.validate_auth_config({})
3641
3742
38class TestDataStoreFileName(unittest.TestCase):43class DeletingContainerClient(object):
3944 """Fake swift client to emulate scenarios for container deletion.
40 def setUp(self):45
41 super(TestDataStoreFileName, self).setUp()46 This is injected into a DataStore object to execute a scenario defined by a
42 # We won't use any method that requires the auth config so we don't47 list of calls.
43 # provide any.48 """
44 self.ds = data_store.DataStore('foo', None)49
4550 def __init__(self, calls):
46 def test_abspath(self):51 # Reverse the list order so we can pop() calls
47 self.assertEquals('foo', self.ds.file_name('/baz/bar/foo'))52 self.calls = list(reversed(calls))
4853
49 def test_relpath(self):54 def delete_container(self, container, *args, **kwargs):
50 self.assertEquals('foo', self.ds.file_name('../baz/bar/foo'))55 call = self.calls.pop()
5156 return call(container, *args, **kwargs)
52 def test_basename(self):57
53 self.assertEquals('foo', self.ds.file_name('foo'))58
5459class DeletingContainerDataStore(data_store.DataStore):
5560 """An instrumented DataStore to emulate errors in container deletion."""
56if __name__ == "__main__":61
57 unittest.main()62 def __init__(self, component, calls):
63 # We don't use the config so we inject a fake but valid one
64 auth_config = {'auth_url': 'http://example.com',
65 'auth_user': 'user',
66 'auth_password': 'pass',
67 'auth_tenant_name': 'tenant',
68 'auth_region': 'region',}
69 super(DeletingContainerDataStore, self).__init__(
70 component, auth_config,
71 # We don't want to wait
72 starting_backoff=0, max_backoff=0)
73 self.client = DeletingContainerClient(calls)
74
75
76class TestDataStoreDelete(unittest.TestCase):
77 """Test the container deletion.
78
79 We don't have a swift server to inject errors into so we're faking that
80 part to exercise the error handling in the DataStore.delete()
81 implementation.
82 """
83 def raise_409(self, container, *args, **kwargs):
84 raise client.ClientException('409', http_status=409)
85
86 def raise_404(self, container, *args, **kwargs):
87 raise client.ClientException('404', http_status=404)
88
89 def test_delete_retried_on_409(self):
90 calls = [self.raise_409,
91 lambda container, *args, **kwargs: None]
92 ds = DeletingContainerDataStore('ignored', calls)
93 ds.delete()
94 # All the calls have been issued
95 self.assertEqual([], ds.client.calls)
96
97 def test_delete_retried_once_then_succeeds(self):
98 calls = [self.raise_409,
99 self.raise_404]
100 ds = DeletingContainerDataStore('ignored', calls)
101 ds.delete()
102 # All the calls have been issued
103 self.assertEqual([], ds.client.calls)
58104
=== modified file 'ci-utils/ci_utils/tests/test_fixtures.py'
--- ci-utils/ci_utils/tests/test_fixtures.py 2014-10-29 13:55:35 +0000
+++ ci-utils/ci_utils/tests/test_fixtures.py 2014-11-20 08:47:10 +0000
@@ -19,6 +19,7 @@
1919
20from ucitests import fixtures as uci_fixtures20from ucitests import fixtures as uci_fixtures
2121
22from ci_utils import data_store
22from ci_utils.testing import fixtures23from ci_utils.testing import fixtures
2324
2425
@@ -91,11 +92,10 @@
91 def test_delete_not_empty(self):92 def test_delete_not_empty(self):
92 self.assertTrue(os.path.exists(self.ds_path))93 self.assertTrue(os.path.exists(self.ds_path))
93 self.ds.put_file('foo', 'foo content\n')94 self.ds.put_file('foo', 'foo content\n')
94 with self.assertRaises(OSError) as cm:95 with self.assertRaises(data_store.DataStoreException) as cm:
95 self.ds.delete()96 self.ds.delete()
96 # FIXME: Ideally the fake data store and the real one should throw the97 self.assertTrue(cm.exception.args[0].endswith("'{}'".format(
97 # same exception in this case, not a big deal -- vila 2014-10-2998 self.ds_path)))
98 self.assertEqual(errno.ENOTEMPTY, cm.exception.args[0])
9999
100100
101class TestUsingFakeDataStore(unittest.TestCase):101class TestUsingFakeDataStore(unittest.TestCase):
102102
=== modified file 'tests/test_data_store.py'
--- tests/test_data_store.py 2014-10-08 09:54:56 +0000
+++ tests/test_data_store.py 2014-11-20 08:47:10 +0000
@@ -59,7 +59,6 @@
59 """ Test adding a file. """59 """ Test adding a file. """
6060
61 self.ds.put_file(self.filename, self.contents)61 self.ds.put_file(self.filename, self.contents)
62 self.addCleanup(self.ds.delete_file, self.filename)
63 files = self.ds.list_files()62 files = self.ds.list_files()
64 self.assertEqual([self.filename], files)63 self.assertEqual([self.filename], files)
6564
@@ -67,18 +66,20 @@
67 """ Test adding a file has correct contents. """66 """ Test adding a file has correct contents. """
6867
69 self.ds.put_file(self.filename, self.contents)68 self.ds.put_file(self.filename, self.contents)
70 self.addCleanup(self.ds.delete_file, self.filename)
71 contents = self.ds.get_file(self.filename)69 contents = self.ds.get_file(self.filename)
7270
73 self.assertEqual(contents, self.contents)71 self.assertEqual(contents, self.contents)
7472
75 def test_public_container(self):73 def test_not_public_fails(self):
76 """ Test accesssing a public container. """74 link = self.ds.put_file(self.filename, self.contents)
75 self.addCleanup(self.ds.delete_file, self.filename)
76 with self.assertRaises(urllib2.HTTPError) as cm:
77 urllib2.urlopen(link)
78 self.assertEqual(401, cm.exception.code)
7779
80 def test_public_succeeds(self):
78 self.ds.put_file(self.filename, self.contents)81 self.ds.put_file(self.filename, self.contents)
79 self.addCleanup(self.ds.delete_file, self.filename)
80 self.ds.change_visibility(public=True)82 self.ds.change_visibility(public=True)
81
82 response = urllib2.urlopen(self.ds.file_path(self.filename))83 response = urllib2.urlopen(self.ds.file_path(self.filename))
83 self.assertEqual(response.getcode(), 200, response.read())84 self.assertEqual(response.getcode(), 200, response.read())
8485
@@ -93,18 +94,6 @@
93 files = self.ds.list_files()94 files = self.ds.list_files()
94 self.assertEqual([], files)95 self.assertEqual([], files)
9596
96 def test_put_file_link(self):
97 """ Test put file returns a valid link. """
98
99 link = self.ds.put_file(self.filename, self.contents)
100 self.addCleanup(self.ds.delete_file, self.filename)
101 with self.assertRaises(urllib2.HTTPError) as cm:
102 resp = urllib2.urlopen(link)
103 self.assertEqual(401, cm.exception.code)
104 self.ds.change_visibility(public=True)
105 resp = urllib2.urlopen(link)
106 self.assertEqual(200, resp.getcode(), resp.read())
107
10897
109class TestDataStoreBadConfigs(unittest.TestCase):98class TestDataStoreBadConfigs(unittest.TestCase):
11099
@@ -132,6 +121,9 @@
132 ds = data_store.DataStore("test", auth_config=conf)121 ds = data_store.DataStore("test", auth_config=conf)
133 ds.ensure_swift_client()122 ds.ensure_swift_client()
134123
124 # FIXME: This needs log_on_failure from
125 # test_runner/tstrun/tests/__init__.py. The needed refactoring would make
126 # the current MP too big -- vila 2014-11-19
135 def test_bad_region(self):127 def test_bad_region(self):
136 conf = self.get_config()128 conf = self.get_config()
137 conf['auth_region'] = "I don't exist"129 conf['auth_region'] = "I don't exist"

Subscribers

People subscribed via source and target branches