14 from arvados_docker import cleaner
16 MAX_DOCKER_ID = (16 ** 64) - 1
20 return '{:064x}'.format(random.randint(0, MAX_DOCKER_ID))
23 def MockContainer(image_hash):
24 return {'Id': MockDockerId(),
25 'Image': image_hash['Id']}
28 def MockImage(*, size=0, vsize=None, tags=[]):
30 vsize = random.randint(100, 2000000)
31 return {'Id': MockDockerId(),
32 'ParentId': MockDockerId(),
33 'RepoTags': list(tags),
38 class MockEvent(dict):
40 event_seq = itertools.count(1)
42 def __init__(self, status, docker_id=None, **event_data):
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))
51 return json.dumps(self).encode(self.ENCODING)
54 class MockException(docker.errors.APIError):
56 def __init__(self, status_code):
57 response = mock.Mock(name='response')
58 response.status_code = status_code
59 super().__init__("mock exception", response)
62 class DockerImageTestCase(unittest.TestCase):
64 def test_used_at_sets_last_used(self):
65 image = cleaner.DockerImage(MockImage())
67 self.assertEqual(5, image.last_used)
69 def test_used_at_moves_forward(self):
70 image = cleaner.DockerImage(MockImage())
73 self.assertEqual(8, image.last_used)
75 def test_used_at_does_not_go_backward(self):
76 image = cleaner.DockerImage(MockImage())
79 self.assertEqual(4, image.last_used)
82 class DockerImagesTestCase(unittest.TestCase):
87 def setup_mock_images(self, *vsizes):
88 self.mock_images.extend(MockImage(vsize=vsize) for vsize in vsizes)
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)
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()))
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']))
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']))
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()))
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()))
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()))
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()))
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()))
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()))
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()))
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)
191 images.end_user(user['Id'])
192 self.assertEqual([self.mock_images[ii]['Id'] for ii in [1, 2]],
193 list(images.should_delete()))
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()))
200 def test_nonexistent_user_removed(self):
201 images = self.setup_images()
202 images.end_user('nonexistent')
203 # No exception should be raised.
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()))
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)
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']))
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()))
232 class DockerImageUseRecorderTestCase(unittest.TestCase):
233 TEST_CLASS = cleaner.DockerImageUseRecorder
234 TEST_CLASS_INIT_KWARGS = {}
237 self.images = mock.MagicMock(name='images')
238 self.docker_client = mock.MagicMock(name='docker_client')
240 self.recorder = self.TEST_CLASS(self.images, self.docker_client,
241 self.encoded_events, **self.TEST_CLASS_INIT_KWARGS)
244 def encoded_events(self):
245 return (event.encoded() for event in self.events)
247 def test_unknown_events_ignored(self):
248 self.events.append(MockEvent('mock!event'))
250 # No exception should be raised.
252 def test_fetches_container_on_create(self):
253 self.events.append(MockEvent('create'))
255 self.docker_client.inspect_container.assert_called_with(
256 self.events[0]['id'])
258 def test_adds_user_on_container_create(self):
259 self.events.append(MockEvent('create'))
261 self.images.add_user.assert_called_with(
262 self.docker_client.inspect_container(), self.events[0]['time'])
264 def test_unknown_image_handling(self):
265 # The use recorder should not fetch any images.
266 self.events.append(MockEvent('create'))
268 self.assertFalse(self.docker_client.inspect_image.called)
270 def test_unfetchable_containers_ignored(self):
271 self.events.append(MockEvent('create'))
272 self.docker_client.inspect_container.side_effect = MockException(404)
274 self.assertFalse(self.images.add_user.called)
276 def test_ends_user_on_container_destroy(self):
277 self.events.append(MockEvent('destroy'))
279 self.images.end_user.assert_called_with(self.events[0]['id'])
282 class DockerImageCleanerTestCase(DockerImageUseRecorderTestCase):
283 TEST_CLASS = cleaner.DockerImageCleaner
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'))
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())
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'))
300 self.docker_client.inspect_image.assert_called_with(
301 self.docker_client.inspect_container()['Image'])
302 self.assertFalse(self.images.add_image.called)
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'))
309 self.docker_client.remove_image.assert_called_with(delete_id)
310 self.images.del_image.assert_called_with(delete_id)
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'))
318 self.docker_client.remove_image.assert_called_with(delete_id)
319 self.assertFalse(self.images.del_image.called)
322 class DockerContainerCleanerTestCase(DockerImageUseRecorderTestCase):
323 TEST_CLASS = cleaner.DockerImageCleaner
324 TEST_CLASS_INIT_KWARGS = {'remove_containers_onexit': True}
326 def test_container_deletion_deletes_volumes(self):
328 self.events.append(MockEvent('die', docker_id=cid))
330 self.docker_client.remove_container.assert_called_with(cid, v=True)
332 @mock.patch('arvados_docker.cleaner.logger')
333 def test_failed_container_deletion_handling(self, mockLogger):
335 self.docker_client.remove_container.side_effect = MockException(500)
336 self.events.append(MockEvent('die', docker_id=cid))
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])
345 class HumanSizeTestCase(unittest.TestCase):
347 def check(self, human_str, count, exp):
348 self.assertEqual(count * (1024 ** exp),
349 cleaner.human_size(human_str))
351 def test_bytes(self):
352 self.check('1', 1, 0)
353 self.check('82', 82, 0)
355 def test_kibibytes(self):
356 self.check('2K', 2, 1)
357 self.check('3k', 3, 1)
359 def test_mebibytes(self):
360 self.check('4M', 4, 2)
361 self.check('5m', 5, 2)
363 def test_gibibytes(self):
364 self.check('6G', 6, 3)
365 self.check('7g', 7, 3)
367 def test_tebibytes(self):
368 self.check('8T', 8, 4)
369 self.check('9t', 9, 4)
372 class RunTestCase(unittest.TestCase):
375 self.config = cleaner.default_config()
376 self.config['Quota'] = 1000000
377 self.docker_client = mock.MagicMock(name='docker_client')
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'])
393 @mock.patch('docker.Client', name='docker_client')
394 @mock.patch('arvados_docker.cleaner.run', name='cleaner_run')
395 class MainTestCase(unittest.TestCase):
397 def test_client_api_version(self, run_mock, docker_client):
398 with tempfile.NamedTemporaryFile(mode='wt') as cf:
399 cf.write('{"Quota":"1000T"}')
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.
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())
413 class ConfigTestCase(unittest.TestCase):
415 def test_load_config(self):
416 with tempfile.NamedTemporaryFile(mode='wt') as cf:
418 '{"Quota":"1000T", "RemoveStoppedContainers":"always", "Verbose":2}')
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'])
425 def test_args_override_config(self):
426 with tempfile.NamedTemporaryFile(mode='wt') as cf:
428 '{"Quota":"1000T", "RemoveStoppedContainers":"always", "Verbose":2}')
430 config = cleaner.load_config([
433 '--remove-stopped-containers', 'never',
436 self.assertEqual(1 << 30, config['Quota'])
437 self.assertEqual('never', config['RemoveStoppedContainers'])
438 self.assertEqual(1, config['Verbose'])
441 class ContainerRemovalTestCase(unittest.TestCase):
442 LIFECYCLE = ['create', 'attach', 'start', 'resize', 'die', 'destroy']
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',
452 # If docker_client.containers() returns non-exited
453 # containers for some reason, do not remove them.
454 'Id': MockDockerId(),
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]
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(
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(
475 self.assertEqual(2, self.docker_client.remove_container.call_count)
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)
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
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)