diff --git a/core/build.gradle b/core/build.gradle index 074273e8fc7..0e584effbff 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -40,7 +40,12 @@ tasks.japicmp { classExcludes = [] - methodExcludes = [] + methodExcludes = [ + "org.testcontainers.utility.ResourceReaper#start(java.lang.String,com.github.dockerjava.api.DockerClient)", + "org.testcontainers.utility.ResourceReaper#start(com.github.dockerjava.api.DockerClient)", + "org.testcontainers.utility.ResourceReaper#registerNetworkForCleanup(java.lang.String)", + "org.testcontainers.utility.ResourceReaper#removeNetworks(java.lang.String)", + ] fieldExcludes = [] } diff --git a/core/src/main/java/org/testcontainers/DockerClientFactory.java b/core/src/main/java/org/testcontainers/DockerClientFactory.java index 1ad7a0bec7e..d0a4e4a4fb3 100644 --- a/core/src/main/java/org/testcontainers/DockerClientFactory.java +++ b/core/src/main/java/org/testcontainers/DockerClientFactory.java @@ -31,6 +31,7 @@ import org.testcontainers.utility.ImageNameSubstitutor; import org.testcontainers.utility.MountableFile; import org.testcontainers.utility.ResourceReaper; +import org.testcontainers.utility.RyukResourceReaper; import org.testcontainers.utility.TestcontainersConfiguration; import java.io.ByteArrayOutputStream; @@ -38,6 +39,7 @@ import java.io.InputStream; import java.net.URI; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -61,8 +63,7 @@ public class DockerClientFactory { public static final String SESSION_ID = UUID.randomUUID().toString(); public static final Map DEFAULT_LABELS = ImmutableMap.of( - TESTCONTAINERS_LABEL, "true", - TESTCONTAINERS_SESSION_ID_LABEL, SESSION_ID + TESTCONTAINERS_LABEL, "true" ); private static final DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.14"); @@ -73,7 +74,7 @@ public class DockerClientFactory { DockerClientProviderStrategy strategy; @VisibleForTesting - DockerClient dockerClient; + DockerClient client; @VisibleForTesting RuntimeException cachedClientFailure; @@ -174,21 +175,20 @@ public String getRemoteDockerUnixSocketPath() { */ @Synchronized public DockerClient client() { - - if (dockerClient != null) { - return dockerClient; - } - // fail-fast if checks have failed previously if (cachedClientFailure != null) { log.debug("There is a cached checks failure - throwing", cachedClientFailure); throw cachedClientFailure; } + if (client != null) { + return client; + } + final DockerClientProviderStrategy strategy = getOrInitializeStrategy(); log.info("Docker host IP address is {}", strategy.getDockerHostIpAddress()); - final DockerClient client = new DockerClientDelegate() { + client = new DockerClientDelegate() { @Getter final DockerClient dockerClient = strategy.getDockerClient(); @@ -209,25 +209,14 @@ public void close() { " Operating System: " + dockerInfo.getOperatingSystem() + "\n" + " Total Memory: " + dockerInfo.getMemTotal() / (1024 * 1024) + " MB"); - final String ryukContainerId; - - boolean useRyuk = !Boolean.parseBoolean(System.getenv("TESTCONTAINERS_RYUK_DISABLED")); - if (useRyuk) { - log.debug("Ryuk is enabled"); - try { - //noinspection deprecation - ryukContainerId = ResourceReaper.start(client); - } catch (RuntimeException e) { - cachedClientFailure = e; - throw e; - } - log.info("Ryuk started - will monitor and terminate Testcontainers containers on JVM exit"); - } else { - log.debug("Ryuk is disabled"); - ryukContainerId = null; - // best-efforts cleanup at JVM shutdown, without using the Ryuk container + final ResourceReaper resourceReaper; + try { + resourceReaper = ResourceReaper.instance(); //noinspection deprecation - ResourceReaper.instance().setHook(); + resourceReaper.init(); + } catch (RuntimeException e) { + cachedClientFailure = e; + throw e; } boolean checksEnabled = !TestcontainersConfiguration.getInstance().isDisableChecks(); @@ -237,6 +226,12 @@ public void close() { try { log.info("Checking the system..."); checkDockerVersion(version.getVersion()); + + //noinspection deprecation + String ryukContainerId = resourceReaper instanceof RyukResourceReaper + ? ((RyukResourceReaper) resourceReaper).getContainerId() + : null; + if (ryukContainerId != null) { checkDiskSpace(client, ryukContainerId); } else { @@ -261,8 +256,7 @@ public void close() { log.debug("Checks are disabled"); } - dockerClient = client; - return dockerClient; + return client; } private void checkDockerVersion(String dockerVersion) { @@ -378,8 +372,10 @@ private T runInsideDocker(DockerClient client, Consumer final String tinyImage = ImageNameSubstitutor.instance().apply(TINY_IMAGE).asCanonicalNameString(); checkAndPullImage(client, tinyImage); + HashMap labels = new HashMap<>(DEFAULT_LABELS); + labels.putAll(ResourceReaper.instance().getLabels()); CreateContainerCmd createContainerCmd = client.createContainerCmd(tinyImage) - .withLabels(DEFAULT_LABELS); + .withLabels(labels); createContainerCmdConsumer.accept(createContainerCmd); String id = createContainerCmd.exec().getId(); diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 3851cf42fca..aca543ef7ec 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -364,7 +364,7 @@ private void tryStart(Instant startedAt) { CreateContainerCmd createCommand = dockerClient.createContainerCmd(dockerImageName); applyConfiguration(createCommand); - createCommand.getLabels().put(DockerClientFactory.TESTCONTAINERS_LABEL, "true"); + createCommand.getLabels().putAll(DockerClientFactory.DEFAULT_LABELS); boolean reused = false; final boolean reusable; @@ -406,7 +406,8 @@ private void tryStart(Instant startedAt) { } if (!reusable) { - createCommand.getLabels().put(DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL, DockerClientFactory.SESSION_ID); + //noinspection deprecation + createCommand.getLabels().putAll(ResourceReaper.instance().getLabels()); } if (!reused) { diff --git a/core/src/main/java/org/testcontainers/containers/Network.java b/core/src/main/java/org/testcontainers/containers/Network.java index 2fda5ad1a8d..046133815b6 100644 --- a/core/src/main/java/org/testcontainers/containers/Network.java +++ b/core/src/main/java/org/testcontainers/containers/Network.java @@ -87,6 +87,8 @@ private String create() { Map labels = createNetworkCmd.getLabels(); labels = new HashMap<>(labels != null ? labels : Collections.emptyMap()); labels.putAll(DockerClientFactory.DEFAULT_LABELS); + //noinspection deprecation + labels.putAll(ResourceReaper.instance().getLabels()); createNetworkCmd.withLabels(labels); return createNetworkCmd.exec().getId(); diff --git a/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java b/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java index 05bb6911c3e..a434dee2b41 100644 --- a/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java +++ b/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java @@ -112,6 +112,8 @@ public void onNext(BuildResponseItem item) { labels.putAll(buildImageCmd.getLabels()); } labels.putAll(DockerClientFactory.DEFAULT_LABELS); + //noinspection deprecation + labels.putAll(ResourceReaper.instance().getLabels()); buildImageCmd.withLabels(labels); prePullDependencyImages(dependencyImageNames); diff --git a/core/src/main/java/org/testcontainers/utility/JVMHookResourceReaper.java b/core/src/main/java/org/testcontainers/utility/JVMHookResourceReaper.java new file mode 100644 index 00000000000..173cf28f97c --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/JVMHookResourceReaper.java @@ -0,0 +1,57 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.PruneType; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * A {@link ResourceReaper} implementation that uses {@link Runtime#addShutdownHook(Thread)} + * to cleanup containers. + */ +class JVMHookResourceReaper extends ResourceReaper { + + @Override + public void init() { + setHook(); + } + + @Override + public synchronized void performCleanup() { + super.performCleanup(); + synchronized (DEATH_NOTE) { + DEATH_NOTE.forEach(filters -> prune(PruneType.CONTAINERS, filters)); + DEATH_NOTE.forEach(filters -> prune(PruneType.NETWORKS, filters)); + DEATH_NOTE.forEach(filters -> prune(PruneType.VOLUMES, filters)); + DEATH_NOTE.forEach(filters -> prune(PruneType.IMAGES, filters)); + } + } + + private void prune(PruneType pruneType, List> filters) { + String[] labels = filters.stream() + .filter(it -> "label".equals(it.getKey())) + .map(Map.Entry::getValue) + .toArray(String[]::new); + switch (pruneType) { + // Docker only prunes stopped containers, so we have to do it manually + case CONTAINERS: + List containers = dockerClient.listContainersCmd() + .withFilter("label", Arrays.asList(labels)) + .withShowAll(true) + .exec(); + + containers.parallelStream().forEach(container -> { + dockerClient.removeContainerCmd(container.getId()) + .withForce(true) + .withRemoveVolumes(true) + .exec(); + }); + break; + default: + dockerClient.pruneCmd(pruneType).withLabelFilter(labels).exec(); + break; + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/ResourceReaper.java b/core/src/main/java/org/testcontainers/utility/ResourceReaper.java index 69c849b6c82..44f9456ce4c 100644 --- a/core/src/main/java/org/testcontainers/utility/ResourceReaper.java +++ b/core/src/main/java/org/testcontainers/utility/ResourceReaper.java @@ -3,9 +3,7 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.exception.NotFoundException; -import com.github.dockerjava.api.model.Container; import com.github.dockerjava.api.model.Network; -import com.github.dockerjava.api.model.PruneType; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Throwables; import com.google.common.collect.Sets; @@ -24,62 +22,62 @@ import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Component that responsible for container removal and automatic cleanup of dead containers at JVM shutdown. */ @Slf4j -public final class ResourceReaper { +public class ResourceReaper { private static final Logger LOGGER = LoggerFactory.getLogger(ResourceReaper.class); + private static final Map MARKER_LABELS = Collections.singletonMap( + DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL, + DockerClientFactory.SESSION_ID + ); + static final List>> DEATH_NOTE = new ArrayList<>( Arrays.asList( - DockerClientFactory.DEFAULT_LABELS.entrySet().stream() + Stream + .concat( + DockerClientFactory.DEFAULT_LABELS.entrySet().stream(), + MARKER_LABELS.entrySet().stream() + ) .>map(it -> new SimpleEntry<>("label", it.getKey() + "=" + it.getValue())) .collect(Collectors.toList()) ) ); private static ResourceReaper instance; - private static AtomicBoolean ryukStarted = new AtomicBoolean(false); - private final DockerClient dockerClient = DockerClientFactory.lazyClient(); + final DockerClient dockerClient = DockerClientFactory.lazyClient(); private Map registeredContainers = new ConcurrentHashMap<>(); private Set registeredNetworks = Sets.newConcurrentHashSet(); private Set registeredImages = Sets.newConcurrentHashSet(); private AtomicBoolean hookIsSet = new AtomicBoolean(false); - /** - * - * @deprecated internal API + * Internal constructor to avoid custom implementations */ - @Deprecated - public static String start(String hostIpAddress, DockerClient client) { - return start(client); - } - - /** - * - * @deprecated internal API - */ - @Deprecated - public static String start(DockerClient client) { - RyukResourceReaper ryuk = new RyukResourceReaper(client); - String containerId = ryuk.getContainerId(); - ryukStarted.set(true); - return containerId; + ResourceReaper() { } public synchronized static ResourceReaper instance() { if (instance == null) { - instance = new ResourceReaper(); + boolean useRyuk = !Boolean.parseBoolean(System.getenv("TESTCONTAINERS_RYUK_DISABLED")); + if (useRyuk) { + //noinspection deprecation + instance = new RyukResourceReaper(); + } else { + instance = new JVMHookResourceReaper(); + } } return instance; @@ -87,8 +85,10 @@ public synchronized static ResourceReaper instance() { /** * Perform a cleanup. + * @deprecated no longer supported API, use {@link DockerClient} directly */ - public synchronized void performCleanup() { + @Deprecated + public void performCleanup() { registeredContainers.forEach(this::removeContainer); registeredNetworks.forEach(this::removeNetwork); registeredImages.forEach(this::removeImage); @@ -126,7 +126,9 @@ public void registerLabelsFilterForCleanup(Map labels) { * * @param containerId the ID of the container * @param imageName the image name of the container (used for logging) + * @deprecated no longer supported API */ + @Deprecated public void registerContainerForCleanup(String containerId, String imageName) { setHook(); registeredContainers.put(containerId, imageName); @@ -136,7 +138,9 @@ public void registerContainerForCleanup(String containerId, String imageName) { * Stop a potentially running container and remove it, including associated volumes. * * @param containerId the ID of the container + * @deprecated use {@link DockerClient} directly */ + @Deprecated public void stopAndRemoveContainer(String containerId) { removeContainer(containerId, registeredContainers.get(containerId)); @@ -148,7 +152,9 @@ public void stopAndRemoveContainer(String containerId) { * * @param containerId the ID of the container * @param imageName the image name of the container (used for logging) + * @deprecated use {@link DockerClient} directly */ + @Deprecated public void stopAndRemoveContainer(String containerId, String imageName) { removeContainer(containerId, imageName); @@ -204,46 +210,24 @@ private void removeContainer(String containerId, String imageName) { * Register a network to be cleaned up at JVM shutdown. * * @param id the ID of the network + * @deprecated no longer supported API */ + @Deprecated public void registerNetworkIdForCleanup(String id) { setHook(); registeredNetworks.add(id); } - /** - * @param networkName the name of the network - * @deprecated see {@link ResourceReaper#registerNetworkIdForCleanup(String)} - */ - @Deprecated - public void registerNetworkForCleanup(String networkName) { - try { - // Try to find the network by name, so that we can register its ID for later deletion - dockerClient.listNetworksCmd() - .withNameFilter(networkName) - .exec() - .forEach(network -> registerNetworkIdForCleanup(network.getId())); - } catch (Exception e) { - LOGGER.trace("Error encountered when looking up network (name: {})", networkName); - } - } - /** * Removes a network by ID. * @param id + * @deprecated use {@link DockerClient} directly */ + @Deprecated public void removeNetworkById(String id) { removeNetwork(id); } - /** - * Removes a network by ID. - * @param identifier - * @deprecated see {@link ResourceReaper#removeNetworkById(String)} - */ - @Deprecated - public void removeNetworks(String identifier) { - removeNetworkById(identifier); - } private void removeNetwork(String id) { try { @@ -273,14 +257,26 @@ private void removeNetwork(String id) { } } + /** + * @deprecated no longer supported API + */ + @Deprecated public void unregisterNetwork(String identifier) { registeredNetworks.remove(identifier); } + /** + * @deprecated no longer supported API + */ + @Deprecated public void unregisterContainer(String identifier) { registeredContainers.remove(identifier); } + /** + * @deprecated no longer supported API + */ + @Deprecated public void registerImageForCleanup(String dockerImageName) { setHook(); registeredImages.add(dockerImageName); @@ -295,53 +291,27 @@ private void removeImage(String dockerImageName) { } } - private void prune(PruneType pruneType, List> filters) { - String[] labels = filters.stream() - .filter(it -> "label".equals(it.getKey())) - .map(Map.Entry::getValue) - .toArray(String[]::new); - switch (pruneType) { - // Docker only prunes stopped containers, so we have to do it manually - case CONTAINERS: - List containers = dockerClient.listContainersCmd() - .withFilter("label", Arrays.asList(labels)) - .withShowAll(true) - .exec(); - - containers.parallelStream().forEach(container -> { - removeContainer(container.getId(), container.getImage()); - }); - break; - default: - dockerClient.pruneCmd(pruneType).withLabelFilter(labels).exec(); - break; + void setHook() { + if (hookIsSet.compareAndSet(false, true)) { + // If the JVM stops without containers being stopped, try and stop the container. + Runtime.getRuntime().addShutdownHook(new Thread(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, this::performCleanup)); } } /** - * @deprecated internal API, not intended for public usage + * + * @deprecated internal API */ @Deprecated - public void setHook() { - if (hookIsSet.compareAndSet(false, true)) { - // If the JVM stops without containers being stopped, try and stop the container. - Runtime.getRuntime().addShutdownHook( - new Thread(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, - () -> { - performCleanup(); - - if (!ryukStarted.get()) { - synchronized (DEATH_NOTE) { - DEATH_NOTE.forEach(filters -> prune(PruneType.CONTAINERS, filters)); - DEATH_NOTE.forEach(filters -> prune(PruneType.NETWORKS, filters)); - DEATH_NOTE.forEach(filters -> prune(PruneType.VOLUMES, filters)); - DEATH_NOTE.forEach(filters -> prune(PruneType.IMAGES, filters)); - } - } - } - ) - ); - } + public Map getLabels() { + return MARKER_LABELS; + } + + /** + * @deprecated internal API + */ + @Deprecated + public void init() { } static class FilterRegistry { diff --git a/core/src/main/java/org/testcontainers/utility/RyukResourceReaper.java b/core/src/main/java/org/testcontainers/utility/RyukResourceReaper.java index e8aed92fa9d..743b33856cf 100644 --- a/core/src/main/java/org/testcontainers/utility/RyukResourceReaper.java +++ b/core/src/main/java/org/testcontainers/utility/RyukResourceReaper.java @@ -29,13 +29,21 @@ import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.awaitility.Awaitility.await; +/** + * Ryuk-based {@link ResourceReaper} implementation. + * + * @see moby-ryuk + * @deprecated internal API + */ +@Deprecated @Slf4j -class RyukResourceReaper { +public class RyukResourceReaper extends ResourceReaper { private static final RateLimiter RYUK_ACK_RATE_LIMITER = RateLimiterBuilder .newBuilder() @@ -43,11 +51,40 @@ class RyukResourceReaper { .withConstantThroughput() .build(); + private final AtomicBoolean started = new AtomicBoolean(false); + @Getter - private final String containerId; + private String containerId = null; + + @Override + public void init() { + if (!TestcontainersConfiguration.getInstance().environmentSupportsReuse()) { + log.debug("Ryuk is enabled"); + maybeStart(); + log.info("Ryuk started - will monitor and terminate Testcontainers containers on JVM exit"); + } else { + log.debug("Ryuk is enabled but will be started on demand"); + } + } + + @Override + public void registerLabelsFilterForCleanup(Map labels) { + maybeStart(); + super.registerLabelsFilterForCleanup(labels); + } + + @Override + public Map getLabels() { + maybeStart(); + return super.getLabels(); + } @SneakyThrows(InterruptedException.class) - RyukResourceReaper(DockerClient client) { + private synchronized void maybeStart() { + if (!started.compareAndSet(false, true)) { + return; + } + DockerClient client = DockerClientFactory.lazyClient(); String ryukImage = ImageNameSubstitutor.instance() .apply(DockerImageName.parse("testcontainers/ryuk:0.3.3")) .asCanonicalNameString(); diff --git a/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java b/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java index d484cc92eaf..16d25b2f625 100644 --- a/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java +++ b/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java @@ -4,17 +4,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.testcontainers.TestImages.TINY_IMAGE; -import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.exception.DockerException; import com.github.dockerjava.api.exception.NotFoundException; import org.junit.Rule; import org.junit.Test; -import org.mockito.Mockito; import org.rnorth.visibleassertions.VisibleAssertions; import org.testcontainers.DockerClientFactory.DiskSpaceUsage; import org.testcontainers.dockerclient.LogToStringContainerCallback; import org.testcontainers.utility.MockTestcontainersConfigurationRule; -import org.testcontainers.utility.TestcontainersConfiguration; /** * Test for {@link DockerClientFactory}. @@ -65,27 +61,15 @@ public void dockerHostIpAddress() { @Test public void failedChecksFailFast() { - Mockito.doReturn(false).when(TestcontainersConfiguration.getInstance()).isDisableChecks(); - - // Make sure that Ryuk is started - assertThat(DockerClientFactory.instance().client()).isNotNull(); - - DockerClientFactory instance = new DockerClientFactory(); - DockerClient dockerClient = instance.dockerClient; + DockerClientFactory instance = DockerClientFactory.instance(); + assertThat(instance.client()).isNotNull(); assertThat(instance.cachedClientFailure).isNull(); try { - // Remove cached client to force the initialization logic - instance.dockerClient = null; - - // Ryuk should fail to start twice due to the name conflict (equal to the session id) - assertThatThrownBy(instance::client).isInstanceOf(DockerException.class); - RuntimeException failure = new IllegalStateException("Boom!"); instance.cachedClientFailure = failure; // Fail fast assertThatThrownBy(instance::client).isEqualTo(failure); } finally { - instance.dockerClient = dockerClient; instance.cachedClientFailure = null; } }