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
1=== modified file 'britney_proxy/britney/process_results.py'
2--- britney_proxy/britney/process_results.py 2014-10-30 17:30:49 +0000
3+++ britney_proxy/britney/process_results.py 2014-11-20 08:47:10 +0000
4@@ -51,11 +51,7 @@
5 # container. This won't scale but we'll revisit the layout when we get a better
6 # understanding of the britney needs (and numbers !) -- vila 2014-10-03
7 class BritneyDataStore(data_store.DataStore):
8-
9- def file_name(self, filename):
10- # Allow paths to be used as object names in the container by overriding
11- # the filtering in the base class.
12- return filename
13+ pass
14
15
16 class BritneyResultHandler(object):
17
18=== modified file 'ci-utils/ci_utils/data_store.py'
19--- ci-utils/ci_utils/data_store.py 2014-10-07 10:04:01 +0000
20+++ ci-utils/ci_utils/data_store.py 2014-11-20 08:47:10 +0000
21@@ -14,10 +14,10 @@
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23
24 import logging
25-import os
26+import time
27+
28
29 from swiftclient import client
30-from swiftclient.client import ClientException
31
32
33 class DataStoreException(Exception):
34@@ -31,11 +31,26 @@
35 class DataStore(object):
36 """Store and retrieve files into a swift container."""
37
38- def __init__(self, component, auth_config, public=False):
39+ def __init__(self, component, auth_config, public=False,
40+ # FIXME: These should really be config options as all data
41+ # stores will use the same but in the mean time, having them
42+ # available here address the test needs -- vila 2014-11-19
43+ starting_backoff=1, max_backoff=64, retries=10):
44 self.container_id = component
45 self.auth_config = auth_config
46 self.public = public
47 self.client = None
48+ # The swiftclient requests are retried with increasing timeouts based
49+ # on 'starting_backoff' and 'max_backoff'. For simplicity, we apply the
50+ # same policy to delete_container() on 409 errors (Conflict: container
51+ # not empty) which swiftclient doesn't take into account.
52+ # FIXME: This implementation misses the random part in the timeout
53+ # calculation -- vila 2014-11-20
54+ self.starting_backoff = starting_backoff
55+ self.max_backoff = max_backoff
56+ # The 7 first backoff values will be: 1, 2, 4... 64 => 127secs, so 10
57+ # retries will give us 5 minutes
58+ self.retries = retries
59
60 @staticmethod
61 def validate_auth_config(auth_config):
62@@ -50,33 +65,45 @@
63
64 if len(missing_fields) > 0:
65 raise DataStoreException(
66- "missing fields: {}".format(', '.join(missing_fields))
67- )
68+ "missing fields: {}".format(', '.join(missing_fields)))
69
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+ """
76 if self.client is not None:
77 return self.client
78
79 self.validate_auth_config(self.auth_config)
80 config = self.auth_config
81- self.client = client.Connection(
82- authurl=config.get('auth_url'),
83- user=config.get('auth_user'),
84- key=config.get('auth_password'),
85- os_options={'tenant_name': config.get('auth_tenant_name'),
86- 'region_name': config.get('auth_region')},
87- auth_version=('/v1.' in config.get('auth_url') and '1.0' or '2.0'))
88+ # All authentication data bar the password so we can log them.
89+ auth = dict(authurl=config.get('auth_url'),
90+ user=config.get('auth_user'),
91+ os_options={'tenant_name': config.get('auth_tenant_name'),
92+ 'region_name': config.get('auth_region')},
93+ auth_version=('/v1.' in config.get('auth_url') and '1.0'
94+ or '2.0'))
95+ self.client = client.Connection(key=config.get('auth_password'),
96+ **auth)
97 try:
98+ # Creating a container is idempotent and can be repeated
99 self._create_container(self.container_id)
100 except client.ClientException:
101+ # This is the first connection attempt in the object life
102 logging.exception('Unable to connect to swift')
103- raise DataStoreException("Missing or invalid authentication info.")
104+ raise DataStoreException(
105+ "Invalid authentication info: {}".format(auth))
106 self.change_visibility(public=self.public)
107 self.container_url = "{}/{}".format(self.client.url, self.container_id)
108
109 def list_files(self):
110 self.ensure_swift_client()
111- _, objects = self.client.get_container(self.container_id)
112+ try:
113+ _, objects = self.client.get_container(self.container_id)
114+ except client.ClientException as e:
115+ raise DataStoreException("Failed to get container: {}".format(e))
116 return [x['name'] for x in objects]
117
118 def put_file(self, filename, contents, content_type=None):
119@@ -86,10 +113,9 @@
120 self.client.put_object(self.container_id, obj=name,
121 contents=contents,
122 content_type=content_type)
123- except ClientException as e:
124+ except client.ClientException as e:
125 raise DataStoreException(
126- "Failed to upload file: {}, Error: {}".format(filename, e)
127- )
128+ "Failed to upload file: {}, Error: {}".format(filename, e))
129
130 return self.file_path(filename)
131
132@@ -100,10 +126,9 @@
133 contents = None
134 try:
135 hdr, contents = self.client.get_object(self.container_id, obj=name)
136- except ClientException as e:
137+ except client.ClientException as e:
138 raise DataStoreException(
139- "Failed to get file: {}, Error: {}".format(filename, e)
140- )
141+ "Failed to get file: {}, Error: {}".format(filename, e))
142 return contents
143
144 def change_visibility(self, public=False):
145@@ -121,40 +146,44 @@
146 name = self.file_name(filename)
147 try:
148 self.client.delete_object(self.container_id, obj=name)
149- except ClientException as e:
150+ except client.ClientException as e:
151 raise DataStoreException(
152- "Failed to delete file: {}, Error: {}".format(filename, e)
153- )
154+ "Failed to delete file: {}, Error: {}".format(filename, e))
155
156 def clear(self):
157+ files = self.list_files()
158+ for fname in files:
159+ self.delete_file(fname)
160+
161+ def delete(self, recursive=False):
162 self.ensure_swift_client()
163+ if recursive:
164+ self.clear()
165 try:
166- files = self.client.get_container(self.container_id)[1]
167-
168- for f in files:
169+ attempts = 0
170+ backoff = self.starting_backoff
171+ while attempts < self.retries:
172 try:
173- self.client.delete_object(self.container_id,
174- obj=f['name'])
175- except ClientException as e:
176- raise DataStoreException(
177- "Failed to delete file: {}".format(e)
178- )
179- except ClientException as e:
180- raise DataStoreException(
181- "Failed to get container: {}".format(e)
182- )
183-
184- def delete(self, recursive=False):
185- self.ensure_swift_client()
186- try:
187- if recursive:
188- self.clear()
189-
190- self.client.delete_container(self.container_id)
191- except ClientException as e:
192- raise DataStoreException(
193- "Failed to delete container: {}".format(e)
194- )
195+ self.client.delete_container(self.container_id)
196+ break
197+ except client.ClientException as e:
198+ if e.http_status == 409:
199+ # The container is not empty yet
200+ pass
201+ elif attempts > 0 and e.http_status == 404:
202+ # The deletion succeeded while we slept, we're done,
203+ # the container is gone
204+ break
205+ else:
206+ msg = 'Failed to delete container (attempts: {}): {}'
207+ raise DataStoreException(msg.format(attempts, e))
208+ # Take a nap before retrying
209+ time.sleep(backoff)
210+ backoff = min(backoff * 2, self.max_backoff)
211+ attempts += 1
212+ except client.ClientException as e:
213+ raise DataStoreException(
214+ "Failed to delete container: {}".format(e))
215
216 def file_name(self, filename):
217 """Returns the file name that is used inside the data store.
218@@ -162,7 +191,7 @@
219 :param filename: The file name used to build the object name inside the
220 container.
221 """
222- return os.path.basename(filename)
223+ return filename
224
225 def file_path(self, filename):
226 name = self.file_name(filename)
227
228=== modified file 'ci-utils/ci_utils/testing/fixtures.py'
229--- ci-utils/ci_utils/testing/fixtures.py 2014-10-29 11:42:52 +0000
230+++ ci-utils/ci_utils/testing/fixtures.py 2014-11-20 08:47:10 +0000
231@@ -89,7 +89,12 @@
232 def delete(self, recursive=False):
233 if recursive:
234 self.clear()
235- os.rmdir(self.container_id)
236+ try:
237+ os.rmdir(self.container_id)
238+ except OSError as e:
239+ if e.errno == errno.ENOTEMPTY:
240+ raise data_store.DataStoreException(
241+ "Failed to delete container: {}".format(e))
242
243 def file_path(self, filename):
244 return '{}{}/{}'.format('file://', self.container_id, filename)
245
246=== modified file 'ci-utils/ci_utils/tests/test_data_store.py'
247--- ci-utils/ci_utils/tests/test_data_store.py 2014-10-07 10:04:01 +0000
248+++ ci-utils/ci_utils/tests/test_data_store.py 2014-11-20 08:47:10 +0000
249@@ -13,9 +13,14 @@
250 # You should have received a copy of the GNU Affero General Public License
251 # along with this program. If not, see <http://www.gnu.org/licenses/>.
252
253+import os
254 import unittest
255
256
257+from ucitests import fixtures
258+from swiftclient import client
259+
260+
261 from ci_utils import data_store
262
263
264@@ -35,23 +40,64 @@
265 data_store.DataStore.validate_auth_config({})
266
267
268-class TestDataStoreFileName(unittest.TestCase):
269-
270- def setUp(self):
271- super(TestDataStoreFileName, self).setUp()
272- # We won't use any method that requires the auth config so we don't
273- # provide any.
274- self.ds = data_store.DataStore('foo', None)
275-
276- def test_abspath(self):
277- self.assertEquals('foo', self.ds.file_name('/baz/bar/foo'))
278-
279- def test_relpath(self):
280- self.assertEquals('foo', self.ds.file_name('../baz/bar/foo'))
281-
282- def test_basename(self):
283- self.assertEquals('foo', self.ds.file_name('foo'))
284-
285-
286-if __name__ == "__main__":
287- unittest.main()
288+class DeletingContainerClient(object):
289+ """Fake swift client to emulate scenarios for container deletion.
290+
291+ This is injected into a DataStore object to execute a scenario defined by a
292+ list of calls.
293+ """
294+
295+ def __init__(self, calls):
296+ # Reverse the list order so we can pop() calls
297+ self.calls = list(reversed(calls))
298+
299+ def delete_container(self, container, *args, **kwargs):
300+ call = self.calls.pop()
301+ return call(container, *args, **kwargs)
302+
303+
304+class DeletingContainerDataStore(data_store.DataStore):
305+ """An instrumented DataStore to emulate errors in container deletion."""
306+
307+ def __init__(self, component, calls):
308+ # We don't use the config so we inject a fake but valid one
309+ auth_config = {'auth_url': 'http://example.com',
310+ 'auth_user': 'user',
311+ 'auth_password': 'pass',
312+ 'auth_tenant_name': 'tenant',
313+ 'auth_region': 'region',}
314+ super(DeletingContainerDataStore, self).__init__(
315+ component, auth_config,
316+ # We don't want to wait
317+ starting_backoff=0, max_backoff=0)
318+ self.client = DeletingContainerClient(calls)
319+
320+
321+class TestDataStoreDelete(unittest.TestCase):
322+ """Test the container deletion.
323+
324+ We don't have a swift server to inject errors into so we're faking that
325+ part to exercise the error handling in the DataStore.delete()
326+ implementation.
327+ """
328+ def raise_409(self, container, *args, **kwargs):
329+ raise client.ClientException('409', http_status=409)
330+
331+ def raise_404(self, container, *args, **kwargs):
332+ raise client.ClientException('404', http_status=404)
333+
334+ def test_delete_retried_on_409(self):
335+ calls = [self.raise_409,
336+ lambda container, *args, **kwargs: None]
337+ ds = DeletingContainerDataStore('ignored', calls)
338+ ds.delete()
339+ # All the calls have been issued
340+ self.assertEqual([], ds.client.calls)
341+
342+ def test_delete_retried_once_then_succeeds(self):
343+ calls = [self.raise_409,
344+ self.raise_404]
345+ ds = DeletingContainerDataStore('ignored', calls)
346+ ds.delete()
347+ # All the calls have been issued
348+ self.assertEqual([], ds.client.calls)
349
350=== modified file 'ci-utils/ci_utils/tests/test_fixtures.py'
351--- ci-utils/ci_utils/tests/test_fixtures.py 2014-10-29 13:55:35 +0000
352+++ ci-utils/ci_utils/tests/test_fixtures.py 2014-11-20 08:47:10 +0000
353@@ -19,6 +19,7 @@
354
355 from ucitests import fixtures as uci_fixtures
356
357+from ci_utils import data_store
358 from ci_utils.testing import fixtures
359
360
361@@ -91,11 +92,10 @@
362 def test_delete_not_empty(self):
363 self.assertTrue(os.path.exists(self.ds_path))
364 self.ds.put_file('foo', 'foo content\n')
365- with self.assertRaises(OSError) as cm:
366+ with self.assertRaises(data_store.DataStoreException) as cm:
367 self.ds.delete()
368- # FIXME: Ideally the fake data store and the real one should throw the
369- # same exception in this case, not a big deal -- vila 2014-10-29
370- self.assertEqual(errno.ENOTEMPTY, cm.exception.args[0])
371+ self.assertTrue(cm.exception.args[0].endswith("'{}'".format(
372+ self.ds_path)))
373
374
375 class TestUsingFakeDataStore(unittest.TestCase):
376
377=== modified file 'tests/test_data_store.py'
378--- tests/test_data_store.py 2014-10-08 09:54:56 +0000
379+++ tests/test_data_store.py 2014-11-20 08:47:10 +0000
380@@ -59,7 +59,6 @@
381 """ Test adding a file. """
382
383 self.ds.put_file(self.filename, self.contents)
384- self.addCleanup(self.ds.delete_file, self.filename)
385 files = self.ds.list_files()
386 self.assertEqual([self.filename], files)
387
388@@ -67,18 +66,20 @@
389 """ Test adding a file has correct contents. """
390
391 self.ds.put_file(self.filename, self.contents)
392- self.addCleanup(self.ds.delete_file, self.filename)
393 contents = self.ds.get_file(self.filename)
394
395 self.assertEqual(contents, self.contents)
396
397- def test_public_container(self):
398- """ Test accesssing a public container. """
399+ def test_not_public_fails(self):
400+ link = self.ds.put_file(self.filename, self.contents)
401+ self.addCleanup(self.ds.delete_file, self.filename)
402+ with self.assertRaises(urllib2.HTTPError) as cm:
403+ urllib2.urlopen(link)
404+ self.assertEqual(401, cm.exception.code)
405
406+ def test_public_succeeds(self):
407 self.ds.put_file(self.filename, self.contents)
408- self.addCleanup(self.ds.delete_file, self.filename)
409 self.ds.change_visibility(public=True)
410-
411 response = urllib2.urlopen(self.ds.file_path(self.filename))
412 self.assertEqual(response.getcode(), 200, response.read())
413
414@@ -93,18 +94,6 @@
415 files = self.ds.list_files()
416 self.assertEqual([], files)
417
418- def test_put_file_link(self):
419- """ Test put file returns a valid link. """
420-
421- link = self.ds.put_file(self.filename, self.contents)
422- self.addCleanup(self.ds.delete_file, self.filename)
423- with self.assertRaises(urllib2.HTTPError) as cm:
424- resp = urllib2.urlopen(link)
425- self.assertEqual(401, cm.exception.code)
426- self.ds.change_visibility(public=True)
427- resp = urllib2.urlopen(link)
428- self.assertEqual(200, resp.getcode(), resp.read())
429-
430
431 class TestDataStoreBadConfigs(unittest.TestCase):
432
433@@ -132,6 +121,9 @@
434 ds = data_store.DataStore("test", auth_config=conf)
435 ds.ensure_swift_client()
436
437+ # FIXME: This needs log_on_failure from
438+ # test_runner/tstrun/tests/__init__.py. The needed refactoring would make
439+ # the current MP too big -- vila 2014-11-19
440 def test_bad_region(self):
441 conf = self.get_config()
442 conf['auth_region'] = "I don't exist"

Subscribers

People subscribed via source and target branches