diff --git a/core/src/main/java/org/testcontainers/DockerClientFactory.java b/core/src/main/java/org/testcontainers/DockerClientFactory.java index 499c4ee4d85..465a694da00 100644 --- a/core/src/main/java/org/testcontainers/DockerClientFactory.java +++ b/core/src/main/java/org/testcontainers/DockerClientFactory.java @@ -42,14 +42,21 @@ public class DockerClientFactory { public static final ThreadGroup TESTCONTAINERS_THREAD_GROUP = new ThreadGroup("testcontainers"); public static final String TESTCONTAINERS_LABEL = DockerClientFactory.class.getPackage().getName(); public static final String TESTCONTAINERS_SESSION_ID_LABEL = TESTCONTAINERS_LABEL + ".sessionId"; + public static final String TESTCONTAINERS_REUSABLE_LABLE = TESTCONTAINERS_LABEL + ".dontkillme"; public static final String SESSION_ID = UUID.randomUUID().toString(); + public static final String MAGIC_WORD = "please"; public static final Map DEFAULT_LABELS = ImmutableMap.of( TESTCONTAINERS_LABEL, "true", TESTCONTAINERS_SESSION_ID_LABEL, SESSION_ID ); + public static final Map REUSABLE_LABELS = ImmutableMap.of( + TESTCONTAINERS_LABEL, "true", + TESTCONTAINERS_REUSABLE_LABLE, MAGIC_WORD + ); + private static final String TINY_IMAGE = TestcontainersConfiguration.getInstance().getTinyImage(); private static DockerClientFactory instance; diff --git a/core/src/main/java/org/testcontainers/containers/Container.java b/core/src/main/java/org/testcontainers/containers/Container.java index 7aeb015e8f2..60179d32ccd 100644 --- a/core/src/main/java/org/testcontainers/containers/Container.java +++ b/core/src/main/java/org/testcontainers/containers/Container.java @@ -503,4 +503,28 @@ ExecResult execInContainer(Charset outputCharset, String... command) void setLinkedContainers(Map linkedContainers); void setWaitStrategy(WaitStrategy waitStrategy); + + /** + * Allow TC reuse previously created container + *

+ * With this mode all TC container settings will be applied only once - at the time of container creation. + * After JVM destruction created container continues to live unaffected. + * Subsequent starts of TC container will find previously created container by specified container name + * and reinitialize it. If container isn't found then new one will be created. If container is found but stopped, + * TC will start it and continue to use started container. + *

+ * Previously created container should have exactly the same image and version as specified in TC container configuration. + * Otherwise {@link ContainerLaunchException} will be raised. + *

+ * Reusing existing container allows to seriously reduce time consumed by container creation and starting + * although it should be used carefully. + *

+ * For CI environments reusable mode can be disabled globally or at container's level: + * - by setting testcontainers.properties containers.is.reusing.enabled.when.reusable value to false + * - by setting ReusableContainerConfiguration.builder().isEnabled(false) + * + * @param configuration reusable configuration containing Docker container name + * @return this + */ + SELF withReuseExistingContainerStrategy(ReusableContainerConfiguration configuration); } diff --git a/core/src/main/java/org/testcontainers/containers/ContainerStrategyType.java b/core/src/main/java/org/testcontainers/containers/ContainerStrategyType.java new file mode 100644 index 00000000000..6a418e525eb --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ContainerStrategyType.java @@ -0,0 +1,9 @@ +package org.testcontainers.containers; + +/** + * @author Eugeny Karpov + */ +public enum ContainerStrategyType { + DISPOSABLE, + REUSABLE +} diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 6d675d3a9a1..bf1460fd2c0 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -18,6 +18,7 @@ import lombok.Setter; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.utils.IOUtils; +import org.apache.commons.lang.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.junit.runner.Description; @@ -71,6 +72,7 @@ import static org.testcontainers.containers.output.OutputFrame.OutputType.STDERR; import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; import static org.testcontainers.utility.CommandLine.runShellCommand; +import static org.testcontainers.utility.DockerUtils.isContainerNameEqual; /** * Base class for that allows a container to be launched and controlled. @@ -168,6 +170,12 @@ public class GenericContainer> @Setter(AccessLevel.NONE) private InspectContainerResponse containerInfo; + @Setter(AccessLevel.NONE) + private ContainerStrategyType strategyType = ContainerStrategyType.DISPOSABLE; + + @Setter(AccessLevel.NONE) + private boolean isReusingEnabledWhenReusable = false; + /** * The approach to determine if the container is ready. */ @@ -237,21 +245,13 @@ protected void doStart() { private void tryStart(Profiler profiler) { try { String dockerImageName = image.get(); - logger().debug("Starting container: {}", dockerImageName); - - logger().info("Creating container for image: {}", dockerImageName); - profiler.start("Create container"); - CreateContainerCmd createCommand = dockerClient.createContainerCmd(dockerImageName); - applyConfiguration(createCommand); - - containerId = createCommand.exec().getId(); - copyToFileContainerPathMap.forEach(this::copyFileToContainer); - containerIsCreated(containerId); - logger().info("Starting container with ID: {}", containerId); - profiler.start("Start container"); - dockerClient.startContainerCmd(containerId).exec(); + if (isReusingEnabledWhenReusable) { + tryUseReusableContainer(profiler, dockerImageName); + } else { + tryStartDisposableContainer(profiler, dockerImageName); + } // For all registered output consumers, start following as close to container startup as possible this.logConsumers.forEach(this::followOutput); @@ -302,6 +302,53 @@ private void tryStart(Profiler profiler) { } } + /** + * Try using reusable container with current container name + */ + private void tryUseReusableContainer(Profiler profiler, String dockerImageName) { + logger().debug("Using reusable container: {} with name: {}", dockerImageName, containerName); + + if (StringUtils.isBlank(containerName)) { + logger().error("Container name cannot be blank when using REUSABLE strategy"); + throw new ContainerLaunchException("Blank container name"); + } + + // find container with the specified name or create new one if none found + InspectContainerResponse createdContainer = dockerClient.listContainersCmd() + .withNameFilter(Collections.singletonList(containerName)) + .withShowAll(true) + .exec() + .stream() + .filter(container -> isContainerNameEqual(container, containerName)) + .map(container -> dockerClient.inspectContainerCmd(container.getId()).exec()) + .findFirst() + .orElseGet(() -> createContainer(profiler, dockerImageName)); + + // check that found image is using exactly the same image + String createdContainerImage = createdContainer.getConfig().getImage(); + if (!dockerImageName.equals(createdContainerImage)) { + logger().error("Found existing container with name {} has unexpected image {}", + createdContainer.getName(), createdContainerImage); + throw new ContainerLaunchException("Unexpected image"); + } + + containerId = createdContainer.getId(); + + // if container is stopped then start it + Boolean isRunning = createdContainer.getState().getRunning(); + if (Boolean.FALSE.equals(isRunning)) { + startContainer(profiler); + } + } + + private void tryStartDisposableContainer(Profiler profiler, String dockerImageName) { + logger().debug("Starting container: {}", dockerImageName); + + createContainer(profiler, dockerImageName); + + startContainer(profiler); + } + /** * Stops the container. */ @@ -409,7 +456,28 @@ public Set getLivenessCheckPortNumbers() { return this.getLivenessCheckPorts(); } + private InspectContainerResponse createContainer(Profiler profiler, String dockerImageName) { + logger().info("Creating container for image: {}", dockerImageName); + profiler.start("Create container"); + + CreateContainerCmd createCommand = dockerClient.createContainerCmd(dockerImageName); + applyConfiguration(createCommand); + + containerId = createCommand.exec().getId(); + copyToFileContainerPathMap.forEach(this::copyFileToContainer); + + return dockerClient.inspectContainerCmd(containerId).exec(); + } + private void applyConfiguration(CreateContainerCmd createCommand) { + /*in reusable mode container should only start with specified name - + to prevent uncontrolled creation of unnamed containers*/ + if (isReusingEnabledWhenReusable) { + if (StringUtils.isBlank(containerName)) { + throw new ContainerLaunchException("Container name must be specified when using REUSABLE strategy"); + } + createCommand.withName(containerName); + } // Set up exposed ports (where there are no host port bindings defined) ExposedPort[] portArray = exposedPorts.stream() @@ -505,11 +573,22 @@ private void applyConfiguration(CreateContainerCmd createCommand) { if (createCommand.getLabels() != null) { combinedLabels.putAll(createCommand.getLabels()); } - combinedLabels.putAll(DockerClientFactory.DEFAULT_LABELS); + + if (isReusingEnabledWhenReusable) { + combinedLabels.putAll(DockerClientFactory.REUSABLE_LABELS); + } else { + combinedLabels.putAll(DockerClientFactory.DEFAULT_LABELS); + } createCommand.withLabels(combinedLabels); } + private void startContainer(Profiler profiler) { + logger().info("Starting container with ID: {}", containerId); + profiler.start("Start container"); + dockerClient.startContainerCmd(containerId).exec(); + } + private Set findLinksFromThisContainer(String alias, LinkableContainer linkableContainer) { return dockerClient.listContainersCmd() .withStatusFilter(Arrays.asList("running")) @@ -1082,6 +1161,19 @@ public SELF withCreateContainerCmdModifier(Consumer modifier return self(); } + /** + * {@inheritDoc} + */ + @Override + public SELF withReuseExistingContainerStrategy(ReusableContainerConfiguration configuration) { + containerName = configuration.getContainerName(); + strategyType = ContainerStrategyType.REUSABLE; + isReusingEnabledWhenReusable = + TestcontainersConfiguration.getInstance().isReusingEnabledWhenReusable() // check global configuration + && configuration.isEnabled(); // check container configuration + return self(); + } + /** * Convenience class with access to non-public members of GenericContainer. * diff --git a/core/src/main/java/org/testcontainers/containers/ReusableContainerConfiguration.java b/core/src/main/java/org/testcontainers/containers/ReusableContainerConfiguration.java new file mode 100644 index 00000000000..b71ae683e51 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ReusableContainerConfiguration.java @@ -0,0 +1,43 @@ +package org.testcontainers.containers; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang.StringUtils; + +/** + * @author Eugeny Karpov + */ +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class ReusableContainerConfiguration { + + private final String containerName; + private final boolean isEnabled; + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String containerName; + private Boolean isEnabled = true; + + public Builder withContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + public Builder isEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + return this; + } + + public ReusableContainerConfiguration build() { + if (StringUtils.isBlank(containerName)) { + throw new IllegalArgumentException("Container name must be specified with REUSABLE mode"); + } + return new ReusableContainerConfiguration(containerName, isEnabled); + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/DockerUtils.java b/core/src/main/java/org/testcontainers/utility/DockerUtils.java new file mode 100644 index 00000000000..f0971eb8e0f --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/DockerUtils.java @@ -0,0 +1,25 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.model.Container; +import lombok.experimental.UtilityClass; + +import java.util.Arrays; + +/** + * @author Eugeny Karpov + */ +@UtilityClass +public class DockerUtils { + + /** + * Check if container has specified name + * + * If container name starts with / (all names in com.github.dockerjava.api.model.Container.names are starting with /) + * then omit first letter + */ + public static boolean isContainerNameEqual(Container container, String containerName) { + return Arrays.stream(container.getNames()) + .map(name -> name.startsWith("/") ? name.substring(1) : name) + .anyMatch(name -> name.equals(containerName)); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java index 567970bc6fc..c96b21e2d94 100644 --- a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java +++ b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java @@ -74,6 +74,11 @@ public String getTransportType() { return properties.getProperty("transport.type", "netty"); } + public boolean isReusingEnabledWhenReusable() { + return Boolean.parseBoolean((String) + properties.getOrDefault("containers.is.reusing.enabled.when.reusable", "true")); + } + @Synchronized public boolean updateGlobalConfig(@NonNull String prop, @NonNull String value) { try { diff --git a/core/src/test/java/org/testcontainers/containers/ReusableGenericContainerTest.java b/core/src/test/java/org/testcontainers/containers/ReusableGenericContainerTest.java new file mode 100644 index 00000000000..4a2cb64dd5c --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ReusableGenericContainerTest.java @@ -0,0 +1,162 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.model.Container; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.testcontainers.containers.ReusableContainerConfiguration.builder; + +/** + * @author Eugeny Karpov + */ +@Slf4j +public class ReusableGenericContainerTest { + + private static final DockerClient dockerClient = DockerClientFactory.instance().client(); + + @Test + public void testCreatingReusableContainer() throws Exception { + String containerName = "testCreatingReusableContainer-consul"; + + stopAndRemoveContainers(containerName); + + try (GenericContainer container = createReusableContainer(containerName)) { + container.start(); + + assertTrue(container.isRunning()); + } + } + + @Test + public void reusePreviouslyStoppedContainer() throws Exception { + String containerName = "reusePreviouslyStoppedContainer-consul"; + + stopAndRemoveContainers(containerName); + + try ( + GenericContainer firstContainerObject = createReusableContainer(containerName); + GenericContainer secondContainerObject = createReusableContainer(containerName) + ) { + firstContainerObject.start(); + String firstContainerId = firstContainerObject.getContainerId(); + + dockerClient.stopContainerCmd(firstContainerId).exec(); + assertFalse(firstContainerObject.isRunning()); + + secondContainerObject.start(); + String secondContainerId = secondContainerObject.getContainerId(); + + assertEquals(firstContainerId, secondContainerId); + assertTrue(secondContainerObject.isRunning()); + } + } + + @Test + public void reuseRunningContainer() throws Exception { + String containerName = "reuseRunningContainer-consul"; + + stopAndRemoveContainers(containerName); + + try ( + GenericContainer firstContainerObject = createAndStartReusableContainer(containerName); + GenericContainer secondContainerObject = createAndStartReusableContainer(containerName) + ) { + assertEquals(firstContainerObject.getContainerId(), secondContainerObject.getContainerId()); + assertTrue(secondContainerObject.isRunning()); + } + } + + @Test + public void disableReusableStrategy() throws Exception { + String containerName = "disableReusableStrategy-consul"; + + stopAndRemoveContainers(containerName); + + // explicitly disable reusable strategy globally + TestcontainersConfiguration.getInstance().updateGlobalConfig("containers.reuse", "false"); + try( + GenericContainer firstContainer = createAndStartReusableContainer(containerName); + GenericContainer secondContainer = createReusableContainer(containerName); + ) { + assertTrue(firstContainer.isRunning()); + assertNotEquals(containerName, firstContainer.getContainerName()); + + TestcontainersConfiguration.getInstance().updateGlobalConfig("containers.reuse", "true"); + + // explicitly disable reusable strategy for one container + secondContainer.withReuseExistingContainerStrategy(builder() + .withContainerName(containerName).isEnabled(false).build()); + secondContainer.start(); + + assertTrue(secondContainer.isRunning()); + assertNotEquals(containerName, secondContainer.getContainerName()); + + assertNotEquals(firstContainer.getContainerName(), secondContainer.getContainerName()); + } finally { + TestcontainersConfiguration.getInstance().updateGlobalConfig("containers.reuse", "true"); + } + } + + @Test(expected = ContainerLaunchException.class) + public void tryWrongImage() throws Exception { + String containerName = "tryWrongImage-consul"; + + stopAndRemoveContainers(containerName); + + try ( + GenericContainer firstContainerObject = createReusableContainer(containerName, "consul:1.2.1"); + GenericContainer secondContainerObject = createReusableContainer(containerName, "consul:1.2.0") + ) { + firstContainerObject.start(); + + secondContainerObject.start(); + } + } + + private GenericContainer createAndStartReusableContainer(String containerName) { + GenericContainer container = createReusableContainer(containerName); + container.start(); + return container; + } + + private GenericContainer createReusableContainer(String containerName) { + return createReusableContainer(containerName, "consul:1.2.1"); + } + + private GenericContainer createReusableContainer(String containerName, String imageName) { + return new GenericContainer<>(imageName) + .withLogConsumer(new Slf4jLogConsumer(log)) + .withReuseExistingContainerStrategy(builder() + .withContainerName(containerName) + .build()); + } + + private void stopAndRemoveContainers(String containerName) { + dockerClient.listContainersCmd() + .withShowAll(true) + .withNameFilter(Collections.singletonList(containerName)) + .exec() + .stream() + .map(Container::getId) + .forEach(containerId -> { + if (isContainerRunning(containerId)) { + dockerClient.stopContainerCmd(containerId).exec(); + } + dockerClient.removeContainerCmd(containerId).exec(); + }); + } + + private Boolean isContainerRunning(String containerId) { + return dockerClient.inspectContainerCmd(containerId).exec().getState().getRunning(); + } +}