Merge branch '9857-cwl-acceptlist-re' refs #9857
[arvados.git] / services / dockercleaner / tests / test_cleaner.py
1 #!/usr/bin/env python3
2
3 import collections
4 import itertools
5 import json
6 import random
7 import tempfile
8 import time
9 import unittest
10
11 import docker
12 import mock
13
14 from arvados_docker import cleaner
15
16 MAX_DOCKER_ID = (16 ** 64) - 1
17
18
19 def MockDockerId():
20     return '{:064x}'.format(random.randint(0, MAX_DOCKER_ID))
21
22
23 def MockContainer(image_hash):
24     return {'Id': MockDockerId(),
25             'Image': image_hash['Id']}
26
27
28 def MockImage(*, size=0, vsize=None, tags=[]):
29     if vsize is None:
30         vsize = random.randint(100, 2000000)
31     return {'Id': MockDockerId(),
32             'ParentId': MockDockerId(),
33             'RepoTags': list(tags),
34             'Size': size,
35             'VirtualSize': vsize}
36
37
38 class MockEvent(dict):
39     ENCODING = 'utf-8'
40     event_seq = itertools.count(1)
41
42     def __init__(self, status, docker_id=None, **event_data):
43         if docker_id is None:
44             docker_id = MockDockerId()
45         super().__init__(self, **event_data)
46         self['status'] = status
47         self['id'] = docker_id
48         self.setdefault('time', next(self.event_seq))
49
50     def encoded(self):
51         return json.dumps(self).encode(self.ENCODING)
52
53
54 class MockException(docker.errors.APIError):
55
56     def __init__(self, status_code):
57         response = mock.Mock(name='response')
58         response.status_code = status_code
59         super().__init__("mock exception", response)
60
61
62 class DockerImageTestCase(unittest.TestCase):
63
64     def test_used_at_sets_last_used(self):
65         image = cleaner.DockerImage(MockImage())
66         image.used_at(5)
67         self.assertEqual(5, image.last_used)
68
69     def test_used_at_moves_forward(self):
70         image = cleaner.DockerImage(MockImage())
71         image.used_at(6)
72         image.used_at(8)
73         self.assertEqual(8, image.last_used)
74
75     def test_used_at_does_not_go_backward(self):
76         image = cleaner.DockerImage(MockImage())
77         image.used_at(4)
78         image.used_at(2)
79         self.assertEqual(4, image.last_used)
80
81
82 class DockerImagesTestCase(unittest.TestCase):
83
84     def setUp(self):
85         self.mock_images = []
86
87     def setup_mock_images(self, *vsizes):
88         self.mock_images.extend(MockImage(vsize=vsize) for vsize in vsizes)
89
90     def setup_images(self, *vsizes, target_size=1000000):
91         self.setup_mock_images(*vsizes)
92         images = cleaner.DockerImages(target_size)
93         for image in self.mock_images:
94             images.add_image(image)
95         return images
96
97     def test_has_image(self):
98         images = self.setup_images(None)
99         self.assertTrue(images.has_image(self.mock_images[0]['Id']))
100         self.assertFalse(images.has_image(MockDockerId()))
101
102     def test_del_image(self):
103         images = self.setup_images(None)
104         images.del_image(self.mock_images[0]['Id'])
105         self.assertFalse(images.has_image(self.mock_images[0]['Id']))
106
107     def test_del_nonexistent_image(self):
108         images = self.setup_images(None)
109         images.del_image(MockDockerId())
110         self.assertTrue(images.has_image(self.mock_images[0]['Id']))
111
112     def test_one_image_always_kept(self):
113         # When crunch-job starts a job, it makes sure each compute node
114         # has the Docker image loaded, then it runs all the tasks with
115         # the assumption the image is on each node.  As long as that's
116         # true, the cleaner should avoid removing every installed image:
117         # crunch-job might be counting on the most recent one to be
118         # available, even if it's not currently in use.
119         images = self.setup_images(None, None, target_size=1)
120         for use_time, image in enumerate(self.mock_images, 1):
121             user = MockContainer(image)
122             images.add_user(user, use_time)
123             images.end_user(user['Id'])
124         self.assertEqual([self.mock_images[0]['Id']],
125                          list(images.should_delete()))
126
127     def test_images_under_target_not_deletable(self):
128         # The images are used in this order.  target_size is set so it
129         # could hold the largest image, but not after the most recently
130         # used image is kept; then we have to fall back to the previous one.
131         images = self.setup_images(20, 30, 40, 10, target_size=45)
132         for use_time, image in enumerate(self.mock_images, 1):
133             user = MockContainer(image)
134             images.add_user(user, use_time)
135             images.end_user(user['Id'])
136         self.assertEqual([self.mock_images[ii]['Id'] for ii in [0, 2]],
137                          list(images.should_delete()))
138
139     def test_images_in_use_not_deletable(self):
140         images = self.setup_images(None, None, target_size=1)
141         users = [MockContainer(image) for image in self.mock_images]
142         images.add_user(users[0], 1)
143         images.add_user(users[1], 2)
144         images.end_user(users[1]['Id'])
145         self.assertEqual([self.mock_images[1]['Id']],
146                          list(images.should_delete()))
147
148     def test_image_deletable_after_unused(self):
149         images = self.setup_images(None, None, target_size=1)
150         users = [MockContainer(image) for image in self.mock_images]
151         images.add_user(users[0], 1)
152         images.add_user(users[1], 2)
153         images.end_user(users[0]['Id'])
154         self.assertEqual([self.mock_images[0]['Id']],
155                          list(images.should_delete()))
156
157     def test_image_not_deletable_if_user_restarts(self):
158         images = self.setup_images(None, target_size=1)
159         user = MockContainer(self.mock_images[-1])
160         images.add_user(user, 1)
161         images.end_user(user['Id'])
162         images.add_user(user, 2)
163         self.assertEqual([], list(images.should_delete()))
164
165     def test_image_not_deletable_if_any_user_remains(self):
166         images = self.setup_images(None, target_size=1)
167         users = [MockContainer(self.mock_images[0]) for ii in range(2)]
168         images.add_user(users[0], 1)
169         images.add_user(users[1], 2)
170         images.end_user(users[0]['Id'])
171         self.assertEqual([], list(images.should_delete()))
172
173     def test_image_deletable_after_all_users_end(self):
174         images = self.setup_images(None, None, target_size=1)
175         users = [MockContainer(self.mock_images[ii]) for ii in [0, 1, 1]]
176         images.add_user(users[0], 1)
177         images.add_user(users[1], 2)
178         images.add_user(users[2], 3)
179         images.end_user(users[1]['Id'])
180         images.end_user(users[2]['Id'])
181         self.assertEqual([self.mock_images[-1]['Id']],
182                          list(images.should_delete()))
183
184     def test_images_suggested_for_deletion_by_lru(self):
185         images = self.setup_images(10, 10, 10, target_size=1)
186         users = [MockContainer(image) for image in self.mock_images]
187         images.add_user(users[0], 3)
188         images.add_user(users[1], 1)
189         images.add_user(users[2], 2)
190         for user in users:
191             images.end_user(user['Id'])
192         self.assertEqual([self.mock_images[ii]['Id'] for ii in [1, 2]],
193                          list(images.should_delete()))
194
195     def test_adding_user_without_image_does_not_implicitly_add_image(self):
196         images = self.setup_images(10)
197         images.add_user(MockContainer(MockImage()), 1)
198         self.assertEqual([], list(images.should_delete()))
199
200     def test_nonexistent_user_removed(self):
201         images = self.setup_images()
202         images.end_user('nonexistent')
203         # No exception should be raised.
204
205     def test_del_image_effective_with_users_present(self):
206         images = self.setup_images(None, target_size=1)
207         user = MockContainer(self.mock_images[0])
208         images.add_user(user, 1)
209         images.del_image(self.mock_images[0]['Id'])
210         images.end_user(user['Id'])
211         self.assertEqual([], list(images.should_delete()))
212
213     def setup_from_daemon(self, *vsizes, target_size=1500000):
214         self.setup_mock_images(*vsizes)
215         docker_client = mock.MagicMock(name='docker_client')
216         docker_client.images.return_value = iter(self.mock_images)
217         return cleaner.DockerImages.from_daemon(target_size, docker_client)
218
219     def test_images_loaded_from_daemon(self):
220         images = self.setup_from_daemon(None, None)
221         for image in self.mock_images:
222             self.assertTrue(images.has_image(image['Id']))
223
224     def test_target_size_set_from_daemon(self):
225         images = self.setup_from_daemon(20, 10, 5, target_size=15)
226         user = MockContainer(self.mock_images[-1])
227         images.add_user(user, 1)
228         self.assertEqual([self.mock_images[0]['Id']],
229                          list(images.should_delete()))
230
231
232 class DockerImageUseRecorderTestCase(unittest.TestCase):
233     TEST_CLASS = cleaner.DockerImageUseRecorder
234     TEST_CLASS_INIT_KWARGS = {}
235
236     def setUp(self):
237         self.images = mock.MagicMock(name='images')
238         self.docker_client = mock.MagicMock(name='docker_client')
239         self.events = []
240         self.recorder = self.TEST_CLASS(self.images, self.docker_client,
241                                         self.encoded_events, **self.TEST_CLASS_INIT_KWARGS)
242
243     @property
244     def encoded_events(self):
245         return (event.encoded() for event in self.events)
246
247     def test_unknown_events_ignored(self):
248         self.events.append(MockEvent('mock!event'))
249         self.recorder.run()
250         # No exception should be raised.
251
252     def test_fetches_container_on_create(self):
253         self.events.append(MockEvent('create'))
254         self.recorder.run()
255         self.docker_client.inspect_container.assert_called_with(
256             self.events[0]['id'])
257
258     def test_adds_user_on_container_create(self):
259         self.events.append(MockEvent('create'))
260         self.recorder.run()
261         self.images.add_user.assert_called_with(
262             self.docker_client.inspect_container(), self.events[0]['time'])
263
264     def test_unknown_image_handling(self):
265         # The use recorder should not fetch any images.
266         self.events.append(MockEvent('create'))
267         self.recorder.run()
268         self.assertFalse(self.docker_client.inspect_image.called)
269
270     def test_unfetchable_containers_ignored(self):
271         self.events.append(MockEvent('create'))
272         self.docker_client.inspect_container.side_effect = MockException(404)
273         self.recorder.run()
274         self.assertFalse(self.images.add_user.called)
275
276     def test_ends_user_on_container_destroy(self):
277         self.events.append(MockEvent('destroy'))
278         self.recorder.run()
279         self.images.end_user.assert_called_with(self.events[0]['id'])
280
281
282 class DockerImageCleanerTestCase(DockerImageUseRecorderTestCase):
283     TEST_CLASS = cleaner.DockerImageCleaner
284
285     def test_unknown_image_handling(self):
286         # The image cleaner should fetch and record new images.
287         self.images.has_image.return_value = False
288         self.events.append(MockEvent('create'))
289         self.recorder.run()
290         self.docker_client.inspect_image.assert_called_with(
291             self.docker_client.inspect_container()['Image'])
292         self.images.add_image.assert_called_with(
293             self.docker_client.inspect_image())
294
295     def test_unfetchable_images_ignored(self):
296         self.images.has_image.return_value = False
297         self.docker_client.inspect_image.side_effect = MockException(404)
298         self.events.append(MockEvent('create'))
299         self.recorder.run()
300         self.docker_client.inspect_image.assert_called_with(
301             self.docker_client.inspect_container()['Image'])
302         self.assertFalse(self.images.add_image.called)
303
304     def test_deletions_after_destroy(self):
305         delete_id = MockDockerId()
306         self.images.should_delete.return_value = [delete_id]
307         self.events.append(MockEvent('destroy'))
308         self.recorder.run()
309         self.docker_client.remove_image.assert_called_with(delete_id)
310         self.images.del_image.assert_called_with(delete_id)
311
312     def test_failed_deletion_handling(self):
313         delete_id = MockDockerId()
314         self.images.should_delete.return_value = [delete_id]
315         self.docker_client.remove_image.side_effect = MockException(500)
316         self.events.append(MockEvent('destroy'))
317         self.recorder.run()
318         self.docker_client.remove_image.assert_called_with(delete_id)
319         self.assertFalse(self.images.del_image.called)
320
321
322 class DockerContainerCleanerTestCase(DockerImageUseRecorderTestCase):
323     TEST_CLASS = cleaner.DockerImageCleaner
324     TEST_CLASS_INIT_KWARGS = {'remove_containers_onexit': True}
325
326     def test_container_deletion_deletes_volumes(self):
327         cid = MockDockerId()
328         self.events.append(MockEvent('die', docker_id=cid))
329         self.recorder.run()
330         self.docker_client.remove_container.assert_called_with(cid, v=True)
331
332     @mock.patch('arvados_docker.cleaner.logger')
333     def test_failed_container_deletion_handling(self, mockLogger):
334         cid = MockDockerId()
335         self.docker_client.remove_container.side_effect = MockException(500)
336         self.events.append(MockEvent('die', docker_id=cid))
337         self.recorder.run()
338         self.docker_client.remove_container.assert_called_with(cid, v=True)
339         self.assertEqual("Failed to remove container %s: %s",
340                          mockLogger.warning.call_args[0][0])
341         self.assertEqual(cid,
342                          mockLogger.warning.call_args[0][1])
343
344
345 class HumanSizeTestCase(unittest.TestCase):
346
347     def check(self, human_str, count, exp):
348         self.assertEqual(count * (1024 ** exp),
349                          cleaner.human_size(human_str))
350
351     def test_bytes(self):
352         self.check('1', 1, 0)
353         self.check('82', 82, 0)
354
355     def test_kibibytes(self):
356         self.check('2K', 2, 1)
357         self.check('3k', 3, 1)
358
359     def test_mebibytes(self):
360         self.check('4M', 4, 2)
361         self.check('5m', 5, 2)
362
363     def test_gibibytes(self):
364         self.check('6G', 6, 3)
365         self.check('7g', 7, 3)
366
367     def test_tebibytes(self):
368         self.check('8T', 8, 4)
369         self.check('9t', 9, 4)
370
371
372 class RunTestCase(unittest.TestCase):
373
374     def setUp(self):
375         self.config = cleaner.default_config()
376         self.config['Quota'] = 1000000
377         self.docker_client = mock.MagicMock(name='docker_client')
378
379     def test_run(self):
380         test_start_time = int(time.time())
381         self.docker_client.events.return_value = []
382         cleaner.run(self.config, self.docker_client)
383         self.assertEqual(2, self.docker_client.events.call_count)
384         event_kwargs = [args[1] for args in
385                         self.docker_client.events.call_args_list]
386         self.assertIn('since', event_kwargs[0])
387         self.assertIn('until', event_kwargs[0])
388         self.assertLessEqual(test_start_time, event_kwargs[0]['until'])
389         self.assertIn('since', event_kwargs[1])
390         self.assertEqual(event_kwargs[0]['until'], event_kwargs[1]['since'])
391
392
393 @mock.patch('docker.Client', name='docker_client')
394 @mock.patch('arvados_docker.cleaner.run', name='cleaner_run')
395 class MainTestCase(unittest.TestCase):
396
397     def test_client_api_version(self, run_mock, docker_client):
398         with tempfile.NamedTemporaryFile(mode='wt') as cf:
399             cf.write('{"Quota":"1000T"}')
400             cf.flush()
401             cleaner.main(['--config', cf.name])
402         self.assertEqual(1, docker_client.call_count)
403         # 1.14 is the first version that's well defined, going back to
404         # Docker 1.2, and still supported up to at least Docker 1.9.
405         # See
406         # <https://docs.docker.com/engine/reference/api/docker_remote_api/>.
407         self.assertEqual('1.14',
408                          docker_client.call_args[1].get('version'))
409         self.assertEqual(1, run_mock.call_count)
410         self.assertIs(run_mock.call_args[0][1], docker_client())
411
412
413 class ConfigTestCase(unittest.TestCase):
414
415     def test_load_config(self):
416         with tempfile.NamedTemporaryFile(mode='wt') as cf:
417             cf.write(
418                 '{"Quota":"1000T", "RemoveStoppedContainers":"always", "Verbose":2}')
419             cf.flush()
420             config = cleaner.load_config(['--config', cf.name])
421         self.assertEqual(1000 << 40, config['Quota'])
422         self.assertEqual("always", config['RemoveStoppedContainers'])
423         self.assertEqual(2, config['Verbose'])
424
425     def test_args_override_config(self):
426         with tempfile.NamedTemporaryFile(mode='wt') as cf:
427             cf.write(
428                 '{"Quota":"1000T", "RemoveStoppedContainers":"always", "Verbose":2}')
429             cf.flush()
430             config = cleaner.load_config([
431                 '--config', cf.name,
432                 '--quota', '1G',
433                 '--remove-stopped-containers', 'never',
434                 '--verbose',
435             ])
436         self.assertEqual(1 << 30, config['Quota'])
437         self.assertEqual('never', config['RemoveStoppedContainers'])
438         self.assertEqual(1, config['Verbose'])
439
440
441 class ContainerRemovalTestCase(unittest.TestCase):
442     LIFECYCLE = ['create', 'attach', 'start', 'resize', 'die', 'destroy']
443
444     def setUp(self):
445         self.config = cleaner.default_config()
446         self.docker_client = mock.MagicMock(name='docker_client')
447         self.existingCID = MockDockerId()
448         self.docker_client.containers.return_value = [{
449             'Id': self.existingCID,
450             'Status': 'Exited (0) 6 weeks ago',
451         }, {
452             # If docker_client.containers() returns non-exited
453             # containers for some reason, do not remove them.
454             'Id': MockDockerId(),
455             'Status': 'Running',
456         }]
457         self.newCID = MockDockerId()
458         self.docker_client.events.return_value = [
459             MockEvent(e, docker_id=self.newCID).encoded()
460             for e in self.LIFECYCLE]
461
462     def test_remove_onexit(self):
463         self.config['RemoveStoppedContainers'] = 'onexit'
464         cleaner.run(self.config, self.docker_client)
465         self.docker_client.remove_container.assert_called_once_with(
466             self.newCID, v=True)
467
468     def test_remove_always(self):
469         self.config['RemoveStoppedContainers'] = 'always'
470         cleaner.run(self.config, self.docker_client)
471         self.docker_client.remove_container.assert_any_call(
472             self.existingCID, v=True)
473         self.docker_client.remove_container.assert_any_call(
474             self.newCID, v=True)
475         self.assertEqual(2, self.docker_client.remove_container.call_count)
476
477     def test_remove_never(self):
478         self.config['RemoveStoppedContainers'] = 'never'
479         cleaner.run(self.config, self.docker_client)
480         self.assertEqual(0, self.docker_client.remove_container.call_count)
481
482     def test_container_exited_between_subscribe_events_and_check_existing(self):
483         self.config['RemoveStoppedContainers'] = 'always'
484         self.docker_client.events.return_value = [
485             MockEvent(e, docker_id=self.existingCID).encoded()
486             for e in ['die', 'destroy']]
487         cleaner.run(self.config, self.docker_client)
488         # Subscribed to events before getting the list of existing
489         # exited containers?
490         self.docker_client.assert_has_calls([
491             mock.call.events(since=mock.ANY),
492             mock.call.containers(filters={'status': 'exited'})])
493         # Asked to delete the container twice?
494         self.docker_client.remove_container.assert_has_calls(
495             [mock.call(self.existingCID, v=True)] * 2)
496         self.assertEqual(2, self.docker_client.remove_container.call_count)