summaryrefslogtreecommitdiffstats
path: root/docker-api/src/test/java/com/yahoo/vespa/hosted/dockerapi/DockerImageGarbageCollectionTest.java
diff options
context:
space:
mode:
Diffstat (limited to 'docker-api/src/test/java/com/yahoo/vespa/hosted/dockerapi/DockerImageGarbageCollectionTest.java')
-rw-r--r--docker-api/src/test/java/com/yahoo/vespa/hosted/dockerapi/DockerImageGarbageCollectionTest.java258
1 files changed, 258 insertions, 0 deletions
diff --git a/docker-api/src/test/java/com/yahoo/vespa/hosted/dockerapi/DockerImageGarbageCollectionTest.java b/docker-api/src/test/java/com/yahoo/vespa/hosted/dockerapi/DockerImageGarbageCollectionTest.java
new file mode 100644
index 00000000000..520f8a74d58
--- /dev/null
+++ b/docker-api/src/test/java/com/yahoo/vespa/hosted/dockerapi/DockerImageGarbageCollectionTest.java
@@ -0,0 +1,258 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.dockerapi;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.dockerjava.api.model.Image;
+import com.yahoo.config.provision.DockerImage;
+import com.yahoo.test.ManualClock;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author freva
+ */
+public class DockerImageGarbageCollectionTest {
+
+ private final ImageGcTester gcTester = new ImageGcTester();
+
+ @Test
+ public void noImagesMeansNoUnusedImages() {
+ gcTester.withExistingImages()
+ .expectDeletedImages();
+ }
+
+ @Test
+ public void singleImageWithoutContainersIsUnused() {
+ gcTester.withExistingImages(new ImageBuilder("image-1"))
+ // Even though nothing is using the image, we will keep it for at least 1h
+ .expectDeletedImagesAfterMinutes(0)
+ .expectDeletedImagesAfterMinutes(30)
+ .expectDeletedImagesAfterMinutes(30, "image-1");
+ }
+
+ @Test
+ public void singleImageWithContainerIsUsed() {
+ gcTester.withExistingImages(ImageBuilder.forId("image-1"))
+ .andExistingContainers(new ContainerLite("container-1", "image-1", "running"))
+ .expectDeletedImages();
+ }
+
+ @Test
+ public void multipleUnusedImagesAreIdentified() {
+ gcTester.withExistingImages(
+ ImageBuilder.forId("image-1"),
+ ImageBuilder.forId("image-2"))
+ .expectDeletedImages("image-1", "image-2");
+ }
+
+ @Test
+ public void multipleUnusedLeavesAreIdentified() {
+ gcTester.withExistingImages(
+ ImageBuilder.forId("parent-image"),
+ ImageBuilder.forId("image-1").withParentId("parent-image"),
+ ImageBuilder.forId("image-2").withParentId("parent-image"))
+ .expectDeletedImages("image-1", "image-2", "parent-image");
+ }
+
+ @Test
+ public void unusedLeafWithUsedSiblingIsIdentified() {
+ gcTester.withExistingImages(
+ ImageBuilder.forId("parent-image"),
+ ImageBuilder.forId("image-1").withParentId("parent-image").withTags("latest"),
+ ImageBuilder.forId("image-2").withParentId("parent-image").withTags("1.24"))
+ .andExistingContainers(new ContainerLite("vespa-node-1", "image-1", "running"))
+ .expectDeletedImages("1.24"); // Deleting the only tag will delete the image
+ }
+
+ @Test
+ public void unusedImagesWithMultipleTags() {
+ gcTester.withExistingImages(
+ ImageBuilder.forId("parent-image"),
+ ImageBuilder.forId("image-1").withParentId("parent-image")
+ .withTags("vespa-6", "vespa-6.28", "vespa:latest"))
+ .expectDeletedImages("vespa-6", "vespa-6.28", "vespa:latest", "parent-image");
+ }
+
+ @Test
+ public void taggedImageWithNoContainersIsUnused() {
+ gcTester.withExistingImages(ImageBuilder.forId("image-1").withTags("vespa-6"))
+ .expectDeletedImages("vespa-6");
+ }
+
+ @Test
+ public void unusedImagesWithSimpleImageGc() {
+ gcTester.withExistingImages(ImageBuilder.forId("parent-image"))
+ .expectDeletedImagesAfterMinutes(30)
+ .withExistingImages(
+ ImageBuilder.forId("parent-image"),
+ ImageBuilder.forId("image-1").withParentId("parent-image"))
+ .expectDeletedImagesAfterMinutes(0)
+ .expectDeletedImagesAfterMinutes(30)
+ // At this point, parent-image has been unused for 1h, but image-1 depends on parent-image and it has
+ // only been unused for 30m, so we cannot delete parent-image yet. 30 mins later both can be removed
+ .expectDeletedImagesAfterMinutes(30, "image-1", "parent-image");
+ }
+
+ @Test
+ public void reDownloadingImageIsNotImmediatelyDeleted() {
+ gcTester.withExistingImages(ImageBuilder.forId("image"))
+ .expectDeletedImages("image") // After 1h we delete image
+ .expectDeletedImagesAfterMinutes(0) // image is immediately re-downloaded, but is not deleted
+ .expectDeletedImagesAfterMinutes(10)
+ .expectDeletedImages("image"); // 1h after re-download it is deleted again
+ }
+
+ @Test
+ public void reDownloadingImageIsNotImmediatelyDeletedWhenDeletingByTag() {
+ gcTester.withExistingImages(ImageBuilder.forId("image").withTags("image-1", "my-tag"))
+ .expectDeletedImages("image-1", "my-tag") // After 1h we delete image
+ .expectDeletedImagesAfterMinutes(0) // image is immediately re-downloaded, but is not deleted
+ .expectDeletedImagesAfterMinutes(10)
+ .expectDeletedImages("image-1", "my-tag"); // 1h after re-download it is deleted again
+ }
+
+ /** Same scenario as in {@link #multipleUnusedImagesAreIdentified()} */
+ @Test
+ public void doesNotDeleteExcludedByIdImages() {
+ gcTester.withExistingImages(
+ ImageBuilder.forId("parent-image"),
+ ImageBuilder.forId("image-1").withParentId("parent-image"),
+ ImageBuilder.forId("image-2").withParentId("parent-image"))
+ // Normally, image-1 and parent-image should also be deleted, but because we exclude image-1
+ // we cannot delete parent-image, so only image-2 is deleted
+ .expectDeletedImages(Collections.singletonList("image-1"), "image-2");
+ }
+
+ /** Same as in {@link #doesNotDeleteExcludedByIdImages()} but with tags */
+ @Test
+ public void doesNotDeleteExcludedByTagImages() {
+ gcTester.withExistingImages(
+ ImageBuilder.forId("parent-image").withTags("rhel-6"),
+ ImageBuilder.forId("image-1").withParentId("parent-image").withTags("vespa:6.288.16"),
+ ImageBuilder.forId("image-2").withParentId("parent-image").withTags("vespa:6.289.94"))
+ .expectDeletedImages(Collections.singletonList("vespa:6.288.16"), "vespa:6.289.94");
+ }
+
+ @Test
+ public void exludingNotDownloadedImageIsNoop() {
+ gcTester.withExistingImages(
+ ImageBuilder.forId("parent-image").withTags("rhel-6"),
+ ImageBuilder.forId("image-1").withParentId("parent-image").withTags("vespa:6.288.16"),
+ ImageBuilder.forId("image-2").withParentId("parent-image").withTags("vespa:6.289.94"))
+ .expectDeletedImages(Collections.singletonList("vespa:6.300.1"), "vespa:6.288.16", "vespa:6.289.94", "rhel-6");
+ }
+
+ private class ImageGcTester {
+ private final DockerImpl docker = mock(DockerImpl.class);
+ private final ManualClock clock = new ManualClock();
+ private final DockerImageGarbageCollector imageGC = new DockerImageGarbageCollector(docker, clock);
+ private final Map<DockerImage, Integer> numDeletes = new HashMap<>();
+ private boolean initialized = false;
+
+ private ImageGcTester withExistingImages(ImageBuilder... images) {
+ when(docker.listAllImages()).thenReturn(Arrays.stream(images)
+ .map(ImageBuilder::toImage)
+ .collect(Collectors.toList()));
+ return this;
+ }
+
+ private ImageGcTester andExistingContainers(ContainerLite... containers) {
+ when(docker.listAllContainers()).thenReturn(List.of(containers));
+ return this;
+ }
+
+ private ImageGcTester expectDeletedImages(String... imageIds) {
+ return expectDeletedImagesAfterMinutes(60, imageIds);
+ }
+
+ private ImageGcTester expectDeletedImages(List<String> except, String... imageIds) {
+ return expectDeletedImagesAfterMinutes(60, except, imageIds);
+ }
+ private ImageGcTester expectDeletedImagesAfterMinutes(int minutesAfter, String... imageIds) {
+ return expectDeletedImagesAfterMinutes(minutesAfter, Collections.emptyList(), imageIds);
+ }
+
+ private ImageGcTester expectDeletedImagesAfterMinutes(int minutesAfter, List<String> except, String... imageIds) {
+ if (!initialized) {
+ // Run once with a very long expiry to initialize internal state of existing images
+ imageGC.deleteUnusedDockerImages(Collections.emptyList(), Duration.ofDays(999));
+ initialized = true;
+ }
+
+ clock.advance(Duration.ofMinutes(minutesAfter));
+
+ imageGC.deleteUnusedDockerImages(
+ except.stream().map(DockerImage::fromString).collect(Collectors.toList()),
+ Duration.ofHours(1).minusSeconds(1));
+
+ Arrays.stream(imageIds)
+ .map(DockerImage::fromString)
+ .forEach(image -> {
+ int newValue = numDeletes.getOrDefault(image, 0) + 1;
+ numDeletes.put(image, newValue);
+ verify(docker, times(newValue)).deleteImage(eq(image));
+ });
+
+ verify(docker, times(numDeletes.values().stream().mapToInt(i -> i).sum())).deleteImage(any());
+ return this;
+ }
+ }
+
+ /**
+ * Serializes object to a JSON string using Jackson, then deserializes it to an instance of toClass
+ * (again using Jackson). This can be used to create Jackson classes with no public constructors.
+ * @throws IllegalArgumentException if Jackson fails to serialize or deserialize.
+ */
+ private static <T> T createFrom(Class<T> toClass, Object object) throws IllegalArgumentException {
+ final String serialized;
+ try {
+ serialized = new ObjectMapper().writeValueAsString(object);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Failed to serialize object " + object + " to "
+ + toClass + " with Jackson: " + e, e);
+ }
+ try {
+ return new ObjectMapper().readValue(serialized, toClass);
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Failed to convert " + serialized + " to "
+ + toClass + " with Jackson: " + e, e);
+ }
+ }
+
+ // Workaround for Image class that can't be instantiated directly in Java (instantiate via Jackson instead).
+ private static class ImageBuilder {
+ // Json property names must match exactly the property names in the Image class.
+ @JsonProperty("Id")
+ private final String id;
+
+ @JsonProperty("ParentId")
+ private String parentId = ""; // docker-java returns empty string and not null if the parent is not present
+
+ @JsonProperty("RepoTags")
+ private String[] repoTags = null;
+
+ private ImageBuilder(String id) { this.id = id; }
+
+ private static ImageBuilder forId(String id) { return new ImageBuilder(id); }
+ private ImageBuilder withParentId(String parentId) { this.parentId = parentId; return this; }
+ private ImageBuilder withTags(String... tags) { this.repoTags = tags; return this; }
+ private Image toImage() { return createFrom(Image.class, this); }
+ }
+}