diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d1fc83dee..8b6aa92c254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to this project will be documented in this file. - Abstracted and changed database init script functionality to support use of SQL-like scripts with non-JDBC connections. ([\#551](https://github.com/testcontainers/testcontainers-java/pull/551)) - Added `JdbcDatabaseContainer(Future)` constructor. ([\#543](https://github.com/testcontainers/testcontainers-java/issues/543)) - Mark DockerMachineClientProviderStrategy as not persistable ([\#593](https://github.com/testcontainers/testcontainers-java/pull/593)) +- Added `waitingFor(String serviceName, WaitStrategy waitStrategy)` and overloaded `withExposedService()` methods to `DockerComposeContainer` to allow user to define `WaitStrategy` for compose containers. ([\#174](https://github.com/testcontainers/testcontainers-java/issues/174) and [\#515](https://github.com/testcontainers/testcontainers-java/issues/515)) +- Deprecated `WaitStrategy` and implementations in favour of classes with same names in `org.testcontainers.containers.strategy` +- Added `ContainerState` interface representing the state of a started container +- Added `WaitStrategyTarget` interface which is the target of the new `WaitStrategy` ## [1.6.0] - 2018-01-28 diff --git a/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java b/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java new file mode 100644 index 00000000000..d4ad51dc1da --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java @@ -0,0 +1,68 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Container; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +/** + * Class to provide a wait strategy target for services started through docker-compose + */ +@EqualsAndHashCode +class ComposeServiceWaitStrategyTarget implements WaitStrategyTarget { + + private final Container container; + private final GenericContainer proxyContainer; + @NonNull + private Map mappedPorts; + @Getter(lazy=true) + private final InspectContainerResponse containerInfo = DockerClientFactory.instance().client().inspectContainerCmd(getContainerId()).exec(); + + ComposeServiceWaitStrategyTarget(Container container, GenericContainer proxyContainer, + @NonNull Map mappedPorts) { + this.container = container; + this.proxyContainer = proxyContainer; + this.mappedPorts = new HashMap<>(mappedPorts); + } + + /** + * {@inheritDoc} + */ + @Override + public List getExposedPorts() { + return new ArrayList<>(this.mappedPorts.keySet()); + } + + /** + * {@inheritDoc} + */ + @Override + public Integer getMappedPort(int originalPort) { + return this.proxyContainer.getMappedPort(this.mappedPorts.get(originalPort)); + } + + /** + * {@inheritDoc} + */ + @Override + public String getContainerIpAddress() { + return proxyContainer.getContainerIpAddress(); + } + + /** + * {@inheritDoc} + */ + @Override + public String getContainerId() { + return this.container.getId(); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/Container.java b/core/src/main/java/org/testcontainers/containers/Container.java index 3b877dfde0d..2a4e260509c 100644 --- a/core/src/main/java/org/testcontainers/containers/Container.java +++ b/core/src/main/java/org/testcontainers/containers/Container.java @@ -1,15 +1,15 @@ package org.testcontainers.containers; import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.model.Bind; import com.github.dockerjava.api.model.Info; import lombok.NonNull; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.startupcheck.StartupCheckStrategy; import org.testcontainers.containers.traits.LinkableContainer; -import org.testcontainers.containers.wait.Wait; -import org.testcontainers.containers.wait.WaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.utility.LogUtils; import org.testcontainers.utility.MountableFile; import java.io.IOException; @@ -22,7 +22,7 @@ import java.util.function.Consumer; import java.util.function.Function; -public interface Container> extends LinkableContainer { +public interface Container> extends LinkableContainer, ContainerState { /** * @return a reference to this container instance, cast to the expected generic type. @@ -131,7 +131,7 @@ default void addFileSystemBind(final String hostPath, final String containerPath /** * Specify the {@link WaitStrategy} to use to determine if the container is ready. * - * @see Wait#defaultWaitStrategy() + * @see org.testcontainers.containers.wait.strategy.Wait#defaultWaitStrategy() * @param waitStrategy the WaitStrategy to use * @return this */ @@ -283,7 +283,7 @@ default SELF withClasspathResourceMapping(final String resourcePath, final Strin /** * Set the duration of waiting time until container treated as started. - * @see WaitStrategy#waitUntilReady(GenericContainer) + * @see WaitStrategy#waitUntilReady(org.testcontainers.containers.wait.strategy.WaitStrategyTarget) * * @param startupTimeout timeout * @return this @@ -297,13 +297,6 @@ default SELF withClasspathResourceMapping(final String resourcePath, final Strin */ SELF withPrivilegedMode(boolean mode); - /** - * Get the IP address that this container may be reached on (may not be the local machine). - * - * @return an IP address - */ - String getContainerIpAddress(); - /** * Only consider a container to have successfully started if it has been running for this duration. The default * value is null; if that's the value, ignore this check. @@ -327,33 +320,6 @@ default SELF withClasspathResourceMapping(final String resourcePath, final Strin */ SELF withWorkingDirectory(String workDir); - /** - * @return is the container currently running? - */ - Boolean isRunning(); - - /** - * Get the actual mapped port for a first port exposed by the container. - * - * @return the port that the exposed port is mapped to - * @throws IllegalStateException if there are no exposed ports - */ - default Integer getFirstMappedPort() { - return getExposedPorts() - .stream() - .findFirst() - .map(this::getMappedPort) - .orElseThrow(() -> new IllegalStateException("Container doesn't expose any ports")); - } - - /** - * Get the actual mapped port for a given port exposed by the container. - * - * @param originalPort the original TCP port that is exposed - * @return the port that the exposed port is mapped to, or null if it is not exposed - */ - Integer getMappedPort(int originalPort); - /** * Resolve Docker image and set it. * @@ -386,7 +352,9 @@ default Integer getFirstMappedPort() { * * @param consumer consumer that the frames should be sent to */ - void followOutput(Consumer consumer); + default void followOutput(Consumer consumer) { + LogUtils.followOutput(DockerClientFactory.instance().client(), getContainerId(), consumer); + } /** * Follow container output, sending each frame (usually, line) to a consumer. This method allows Stdout and/or stderr @@ -395,7 +363,9 @@ default Integer getFirstMappedPort() { * @param consumer consumer that the frames should be sent to * @param types types that should be followed (one or both of STDOUT, STDERR) */ - void followOutput(Consumer consumer, OutputFrame.OutputType... types); + default void followOutput(Consumer consumer, OutputFrame.OutputType... types) { + LogUtils.followOutput(DockerClientFactory.instance().client(), getContainerId(), consumer, types); + } /** @@ -419,7 +389,7 @@ default Integer getFirstMappedPort() { * Run a command inside a running container, as though using "docker exec", and interpreting * the output as UTF8. *

- * @see #execInContainer(Charset, String...) + * @see ExecInContainerPattern#execInContainer(com.github.dockerjava.api.command.InspectContainerResponse, String...) */ ExecResult execInContainer(String... command) throws UnsupportedOperationException, IOException, InterruptedException; @@ -427,14 +397,7 @@ ExecResult execInContainer(String... command) /** * Run a command inside a running container, as though using "docker exec". *

- * This functionality is not available on a docker daemon running the older "lxc" execution driver. At - * the time of writing, CircleCI was using this driver. - * @param outputCharset the character set used to interpret the output. - * @param command the parts of the command to run - * @return the result of execution - * @throws IOException if there's an issue communicating with Docker - * @throws InterruptedException if the thread waiting for the response is interrupted - * @throws UnsupportedOperationException if the docker daemon you're connecting to doesn't support "exec". + * @see ExecInContainerPattern#execInContainer(com.github.dockerjava.api.command.InspectContainerResponse, Charset, String...) */ ExecResult execInContainer(Charset outputCharset, String... command) throws UnsupportedOperationException, IOException, InterruptedException; @@ -460,8 +423,6 @@ ExecResult execInContainer(Charset outputCharset, String... command) */ void copyFileFromContainer(String containerPath, String destinationPath) throws IOException, InterruptedException; - List getExposedPorts(); - List getPortBindings(); List getExtraHosts(); @@ -496,17 +457,6 @@ ExecResult execInContainer(Charset outputCharset, String... command) @Deprecated Info getDockerDaemonInfo(); - String getContainerId(); - - String getContainerName(); - - /** - * - * @deprecated please use {@code org.testcontainers.DockerClientFactory.instance().client().inspectContainerCmd(container.getContainerId()).exec()} - */ - @Deprecated - InspectContainerResponse getContainerInfo(); - void setExposedPorts(List exposedPorts); void setPortBindings(List portBindings); diff --git a/core/src/main/java/org/testcontainers/containers/ContainerState.java b/core/src/main/java/org/testcontainers/containers/ContainerState.java new file mode 100644 index 00000000000..842a1cb6bde --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ContainerState.java @@ -0,0 +1,114 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.exception.DockerException; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Ports; +import com.google.common.base.Preconditions; +import org.testcontainers.DockerClientFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public interface ContainerState { + + /** + * Get the IP address that this container may be reached on (may not be the local machine). + * + * @return an IP address + */ + default String getContainerIpAddress() { + return DockerClientFactory.instance().dockerHostIpAddress(); + } + + /** + * @return is the container currently running? + */ + default Boolean isRunning() { + try { + return getContainerId() != null && DockerClientFactory.instance().client().inspectContainerCmd(getContainerId()).exec().getState().getRunning(); + } catch (DockerException e) { + return false; + } + } + + /** + * Get the actual mapped port for a first port exposed by the container. + * + * @return the port that the exposed port is mapped to + * @throws IllegalStateException if there are no exposed ports + */ + default Integer getFirstMappedPort() { + return getExposedPorts() + .stream() + .findFirst() + .map(this::getMappedPort) + .orElseThrow(() -> new IllegalStateException("Container doesn't expose any ports")); + } + + /** + * Get the actual mapped port for a given port exposed by the container. + * + * @param originalPort the original TCP port that is exposed + * @return the port that the exposed port is mapped to, or null if it is not exposed + */ + default Integer getMappedPort(int originalPort) { + Preconditions.checkState(this.getContainerId() != null, "Mapped port can only be obtained after the container is started"); + + Ports.Binding[] binding = new Ports.Binding[0]; + final InspectContainerResponse containerInfo = this.getContainerInfo(); + if (containerInfo != null) { + binding = containerInfo.getNetworkSettings().getPorts().getBindings().get(new ExposedPort(originalPort)); + } + + if (binding != null && binding.length > 0 && binding[0] != null) { + return Integer.valueOf(binding[0].getHostPortSpec()); + } else { + throw new IllegalArgumentException("Requested port (" + originalPort + ") is not mapped"); + } + } + + /** + * @return the exposed ports + */ + List getExposedPorts(); + + /** + * @return the port bindings + */ + default List getPortBindings() { + List portBindings = new ArrayList<>(); + final Ports hostPortBindings = this.getContainerInfo().getHostConfig().getPortBindings(); + for (Map.Entry binding : hostPortBindings.getBindings().entrySet()) { + for (Ports.Binding portBinding : binding.getValue()) { + portBindings.add(String.format("%s:%s", portBinding.toString(), binding.getKey())); + } + } + return portBindings; + } + + /** + * @return the bound port numbers + */ + default List getBoundPortNumbers() { + return getPortBindings().stream() + .map(PortBinding::parse) + .map(PortBinding::getBinding) + .map(Ports.Binding::getHostPortSpec) + .map(Integer::valueOf) + .collect(Collectors.toList()); + } + + /** + * @return the id of the container + */ + String getContainerId(); + + /** + * @return the container info + */ + InspectContainerResponse getContainerInfo(); +} diff --git a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java index 9b0356b55ea..49f31448616 100644 --- a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java +++ b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java @@ -7,6 +7,7 @@ import com.google.common.base.Splitter; import com.google.common.collect.Maps; import com.google.common.util.concurrent.Uninterruptibles; +import lombok.NonNull; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.SystemUtils; import org.junit.runner.Description; @@ -14,9 +15,11 @@ import org.slf4j.LoggerFactory; import org.slf4j.profiler.Profiler; import org.testcontainers.DockerClientFactory; -import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.output.Slf4jLogConsumer; import org.testcontainers.containers.startupcheck.IndefiniteWaitOneShotStartupCheckStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; import org.testcontainers.utility.*; import org.zeroturnaround.exec.InvalidExitValueException; import org.zeroturnaround.exec.ProcessExecutor; @@ -26,8 +29,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.*; +import java.time.Duration; import java.util.AbstractMap.SimpleEntry; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -60,6 +69,8 @@ public class DockerComposeContainer> e private final AtomicInteger nextAmbassadorPort = new AtomicInteger(2000); private final Map> ambassadorPortMappings = new ConcurrentHashMap<>(); + private final Map serviceInstanceMap = new ConcurrentHashMap<>(); + private final Map waitStrategyMap = new ConcurrentHashMap<>(); private final SocatContainer ambassadorContainer = new SocatContainer(); private static final Object MUTEX = new Object(); @@ -112,10 +123,8 @@ public void starting(Description description) { } applyScaling(); // scale before up, so that all scaled instances are available first for linking createServices(); - if (tailChildContainers) { - tailChildContainerLogs(); - } startAmbassadorContainers(profiler); + waitUntilServiceStarted(); } } @@ -129,14 +138,34 @@ private void createServices() { runWithCompose("up -d"); } - private void tailChildContainerLogs() { - listChildContainers().forEach(container -> - LogUtils.followOutput(dockerClient, - container.getId(), - new Slf4jLogConsumer(logger()).withPrefix(container.getNames()[0]), - OutputFrame.OutputType.STDOUT, - OutputFrame.OutputType.STDERR) - ); + private void waitUntilServiceStarted() { + listChildContainers().forEach(this::createServiceInstance); + serviceInstanceMap.forEach(this::waitUntilServiceStarted); + } + + private void createServiceInstance(Container container) { + String serviceName = getServiceNameFromContainer(container); + final ComposeServiceWaitStrategyTarget containerInstance = new ComposeServiceWaitStrategyTarget(container, + ambassadorContainer, ambassadorPortMappings.getOrDefault(serviceName, new HashMap<>())); + + if (tailChildContainers) { + LogUtils.followOutput(DockerClientFactory.instance().client(), containerInstance.getContainerId(), + new Slf4jLogConsumer(logger()).withPrefix(container.getNames()[0])); + } + serviceInstanceMap.putIfAbsent(serviceName, containerInstance); + } + + private void waitUntilServiceStarted(String serviceName, ComposeServiceWaitStrategyTarget serviceInstance) { + final WaitAllStrategy waitAllStrategy = waitStrategyMap.get(serviceName); + if(waitAllStrategy != null) { + waitAllStrategy.waitUntilReady(serviceInstance); + } + } + + private String getServiceNameFromContainer(Container container) { + final String containerName = container.getLabels().get("com.docker.compose.service"); + final String containerNumber = container.getLabels().get("com.docker.compose.container-number"); + return String.format("%s_%s", containerName, containerNumber); } private void runWithCompose(String cmd) { @@ -227,10 +256,20 @@ public void finished(Description description) { } public SELF withExposedService(String serviceName, int servicePort) { + return withExposedService(serviceName, servicePort, Wait.defaultWaitStrategy()); + } - if (!serviceName.matches(".*_[0-9]+")) { - serviceName += "_1"; // implicit first instance of this service - } + public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort) { + return withExposedService(serviceName + "_" + instance, servicePort); + } + + public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort, WaitStrategy waitStrategy) { + return withExposedService(serviceName + "_" + instance, servicePort, waitStrategy); + } + + public SELF withExposedService(String serviceName, int servicePort, @NonNull WaitStrategy waitStrategy) { + + String serviceInstanceName = getServiceInstanceName(serviceName); /* * For every service/port pair that needs to be exposed, we register a target on an 'ambassador container'. @@ -248,14 +287,44 @@ public SELF withExposedService(String serviceName, int servicePort) { // Ambassador container will be started together after docker compose has started int ambassadorPort = nextAmbassadorPort.getAndIncrement(); - ambassadorPortMappings.computeIfAbsent(serviceName, __ -> new ConcurrentHashMap<>()).put(servicePort, ambassadorPort); - ambassadorContainer.withTarget(ambassadorPort, serviceName, servicePort); - ambassadorContainer.addLink(new FutureContainer(this.project + "_" + serviceName), serviceName); + ambassadorPortMappings.computeIfAbsent(serviceInstanceName, __ -> new ConcurrentHashMap<>()).put(servicePort, ambassadorPort); + ambassadorContainer.withTarget(ambassadorPort, serviceInstanceName, servicePort); + ambassadorContainer.addLink(new FutureContainer(this.project + "_" + serviceInstanceName), serviceInstanceName); + addWaitStrategy(serviceInstanceName, waitStrategy); return self(); } - public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort) { - return withExposedService(serviceName + "_" + instance, servicePort); + private String getServiceInstanceName(String serviceName) { + String serviceInstanceName = serviceName; + if (!serviceInstanceName.matches(".*_[0-9]+")) { + serviceInstanceName += "_1"; // implicit first instance of this service + } + return serviceInstanceName; + } + + /* + * can have multiple wait strategies for a single container, e.g. if waiting on several ports + * if no wait strategy is defined, the WaitAllStrategy will return immediately. + * The WaitAllStrategy uses an long timeout, because timeouts should be handled by the inner strategies. + */ + private void addWaitStrategy(String serviceInstanceName, @NonNull WaitStrategy waitStrategy) { + final WaitAllStrategy waitAllStrategy = waitStrategyMap.computeIfAbsent(serviceInstanceName, __ -> + (WaitAllStrategy) new WaitAllStrategy().withStartupTimeout(Duration.ofMinutes(30))); + waitAllStrategy.withStrategy(waitStrategy); + } + + /** + Specify the {@link WaitStrategy} to use to determine if the container is ready. + * + * @see org.testcontainers.containers.wait.strategy.Wait#defaultWaitStrategy() + * @param serviceName the name of the service to wait for + * @param waitStrategy the WaitStrategy to use + * @return this + */ + public SELF waitingFor(String serviceName, @NonNull WaitStrategy waitStrategy) { + String serviceInstanceName = getServiceInstanceName(serviceName); + addWaitStrategy(serviceInstanceName, waitStrategy); + return self(); } /** diff --git a/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java b/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java new file mode 100644 index 00000000000..976ec4a54f0 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java @@ -0,0 +1,102 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.ExecCreateCmdResponse; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.exception.DockerException; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.FrameConsumerResultCallback; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.utility.TestEnvironment; + +import java.io.IOException; +import java.nio.charset.Charset; + +/** + * Provides utility methods for executing commands in containers + */ +@UtilityClass +@Slf4j +public class ExecInContainerPattern { + + /** + * Run a command inside a running container, as though using "docker exec", and interpreting + * the output as UTF8. + *

+ * @param containerInfo the container info + * @param command the command to execute + * @see #execInContainer(InspectContainerResponse, Charset, String...) + */ + public Container.ExecResult execInContainer(InspectContainerResponse containerInfo, String... command) + throws UnsupportedOperationException, IOException, InterruptedException { + return execInContainer(containerInfo, Charset.forName("UTF-8"), command); + } + + /** + * Run a command inside a running container, as though using "docker exec". + *

+ * This functionality is not available on a docker daemon running the older "lxc" execution driver. At + * the time of writing, CircleCI was using this driver. + * @param containerInfo the container info + * @param outputCharset the character set used to interpret the output. + * @param command the parts of the command to run + * @return the result of execution + * @throws IOException if there's an issue communicating with Docker + * @throws InterruptedException if the thread waiting for the response is interrupted + * @throws UnsupportedOperationException if the docker daemon you're connecting to doesn't support "exec". + */ + public Container.ExecResult execInContainer(InspectContainerResponse containerInfo, Charset outputCharset, String... command) + throws UnsupportedOperationException, IOException, InterruptedException { + if (!TestEnvironment.dockerExecutionDriverSupportsExec()) { + // at time of writing, this is the expected result in CircleCI. + throw new UnsupportedOperationException( + "Your docker daemon is running the \"lxc\" driver, which doesn't support \"docker exec\"."); + + } + + if (!isRunning(containerInfo)) { + throw new IllegalStateException("execInContainer can only be used while the Container is running"); + } + + String containerId = containerInfo.getId(); + String containerName = containerInfo.getName(); + + DockerClient dockerClient = DockerClientFactory.instance().client(); + + dockerClient + .execCreateCmd(containerId) + .withCmd(command); + + log.debug("{}: Running \"exec\" command: {}", containerName, String.join(" ", command)); + final ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId) + .withAttachStdout(true).withAttachStderr(true).withCmd(command).exec(); + + final ToStringConsumer stdoutConsumer = new ToStringConsumer(); + final ToStringConsumer stderrConsumer = new ToStringConsumer(); + + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer); + callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer); + + dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion(); + + final Container.ExecResult result = new Container.ExecResult( + stdoutConsumer.toString(outputCharset), + stderrConsumer.toString(outputCharset)); + + log.trace("{}: stdout: {}", containerName, result.getStdout()); + log.trace("{}: stderr: {}", containerName, result.getStderr()); + return result; + } + + private boolean isRunning(InspectContainerResponse containerInfo) { + try { + return containerInfo != null && containerInfo.getState().getRunning(); + } catch (DockerException e) { + return false; + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 859b452ea71..86949d755a4 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -2,13 +2,20 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; -import com.github.dockerjava.api.command.ExecCreateCmdResponse; import com.github.dockerjava.api.command.InspectContainerResponse; -import com.github.dockerjava.api.exception.DockerException; -import com.github.dockerjava.api.model.*; -import com.google.common.base.Preconditions; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Info; +import com.github.dockerjava.api.model.Link; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Volume; +import com.github.dockerjava.api.model.VolumesFrom; import com.google.common.base.Strings; -import lombok.*; +import lombok.AccessLevel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.Setter; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.utils.IOUtils; import org.jetbrains.annotations.NotNull; @@ -23,13 +30,13 @@ import org.testcontainers.containers.output.FrameConsumerResultCallback; import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.output.Slf4jLogConsumer; -import org.testcontainers.containers.output.ToStringConsumer; import org.testcontainers.containers.startupcheck.IsRunningStartupCheckStrategy; import org.testcontainers.containers.startupcheck.MinimumDurationRunningStartupCheckStrategy; import org.testcontainers.containers.startupcheck.StartupCheckStrategy; import org.testcontainers.containers.traits.LinkableContainer; import org.testcontainers.containers.wait.Wait; import org.testcontainers.containers.wait.WaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import org.testcontainers.images.RemoteDockerImage; import org.testcontainers.utility.*; @@ -39,7 +46,16 @@ import java.nio.charset.Charset; import java.nio.file.Path; import java.time.Duration; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -59,7 +75,7 @@ @EqualsAndHashCode(callSuper = false) public class GenericContainer> extends FailureDetectingExternalResource - implements Container, AutoCloseable { + implements Container, AutoCloseable, WaitStrategyTarget { private static final Charset UTF8 = Charset.forName("UTF-8"); @@ -140,15 +156,14 @@ public class GenericContainer> @Setter(AccessLevel.NONE) protected String containerName; + @Setter(AccessLevel.NONE) + private InspectContainerResponse containerInfo; + /** * The approach to determine if the container is ready. */ @NonNull - protected WaitStrategy waitStrategy = Wait.defaultWaitStrategy(); - - @Nullable - @Setter(AccessLevel.NONE) - private InspectContainerResponse containerInfo; + protected org.testcontainers.containers.wait.strategy.WaitStrategy waitStrategy = Wait.defaultWaitStrategy(); private List> logConsumers = new ArrayList<>(); @@ -343,13 +358,13 @@ protected Integer getLivenessCheckPort() { /** * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead */ @NotNull @NonNull + @Deprecated protected Set getLivenessCheckPorts() { - final Set result = new HashSet<>(); - result.addAll(getExposedPortNumbers()); - result.addAll(getBoundPortNumbers()); + final Set result = WaitStrategyTarget.super.getLivenessCheckPortNumbers(); // for backwards compatibility if (this.getLivenessCheckPort() != null) { @@ -359,19 +374,9 @@ protected Set getLivenessCheckPorts() { return result; } - private List getExposedPortNumbers() { - return exposedPorts.stream() - .map(this::getMappedPort) - .collect(Collectors.toList()); - } - - private List getBoundPortNumbers() { - return portBindings.stream() - .map(PortBinding::parse) - .map(PortBinding::getBinding) - .map(Ports.Binding::getHostPortSpec) - .map(Integer::valueOf) - .collect(Collectors.toList()); + @Override + public Set getLivenessCheckPortNumbers() { + return this.getLivenessCheckPorts(); } private void applyConfiguration(CreateContainerCmd createCommand) { @@ -495,7 +500,7 @@ private Set findAllNetworksForLinkedContainers(LinkableContainer linkabl * {@inheritDoc} */ @Override - public SELF waitingFor(@NonNull WaitStrategy waitStrategy) { + public SELF waitingFor(@NonNull org.testcontainers.containers.wait.strategy.WaitStrategy waitStrategy) { this.waitStrategy = waitStrategy; return self(); } @@ -506,16 +511,21 @@ public SELF waitingFor(@NonNull WaitStrategy waitStrategy) { * * @return the {@link WaitStrategy} to use */ - protected WaitStrategy getWaitStrategy() { + protected org.testcontainers.containers.wait.strategy.WaitStrategy getWaitStrategy() { return waitStrategy; } + @Override + public void setWaitStrategy(org.testcontainers.containers.wait.strategy.WaitStrategy waitStrategy) { + this.waitStrategy = waitStrategy; + } + /** * Wait until the container has started. The default implementation simply * waits for a port to start listening; other implementations are available - * as implementations of {@link WaitStrategy} + * as implementations of {@link org.testcontainers.containers.wait.strategy.WaitStrategy} * - * @see #waitingFor(WaitStrategy) + * @see #waitingFor(org.testcontainers.containers.wait.strategy.WaitStrategy) */ protected void waitUntilContainerStarted() { getWaitStrategy().waitUntilReady(this); @@ -770,14 +780,6 @@ public SELF withPrivilegedMode(boolean mode) { return self(); } - /** - * {@inheritDoc} - */ - @Override - public String getContainerIpAddress() { - return DockerClientFactory.instance().dockerHostIpAddress(); - } - /** * {@inheritDoc} */ @@ -816,38 +818,6 @@ public String getIpAddress() { return getContainerIpAddress(); } - /** - * {@inheritDoc} - */ - @Override - public Boolean isRunning() { - try { - return containerId != null && dockerClient.inspectContainerCmd(containerId).exec().getState().getRunning(); - } catch (DockerException e) { - return false; - } - } - - /** - * {@inheritDoc} - */ - @Override - public Integer getMappedPort(final int originalPort) { - - Preconditions.checkState(containerId != null, "Mapped port can only be obtained after the container is started"); - - Ports.Binding[] binding = new Ports.Binding[0]; - if (containerInfo != null) { - binding = containerInfo.getNetworkSettings().getPorts().getBindings().get(new ExposedPort(originalPort)); - } - - if (binding != null && binding.length > 0 && binding[0] != null) { - return Integer.valueOf(binding[0].getHostPortSpec()); - } else { - throw new IllegalArgumentException("Requested port (" + originalPort + ") is not mapped"); - } - } - /** * {@inheritDoc} */ @@ -904,22 +874,6 @@ public String getTestHostIpAddress() { } } - /** - * {@inheritDoc} - */ - @Override - public void followOutput(Consumer consumer) { - this.followOutput(consumer, OutputFrame.OutputType.STDOUT, OutputFrame.OutputType.STDERR); - } - - /** - * {@inheritDoc} - */ - @Override - public void followOutput(Consumer consumer, OutputFrame.OutputType... types) { - LogUtils.followOutput(dockerClient, containerId, consumer, types); - } - /** * {@inheritDoc} */ @@ -993,42 +947,7 @@ public void copyFileFromContainer(String containerPath, String destinationPath) @Override public ExecResult execInContainer(Charset outputCharset, String... command) throws UnsupportedOperationException, IOException, InterruptedException { - - if (!TestEnvironment.dockerExecutionDriverSupportsExec()) { - // at time of writing, this is the expected result in CircleCI. - throw new UnsupportedOperationException( - "Your docker daemon is running the \"lxc\" driver, which doesn't support \"docker exec\"."); - - } - - if (!isRunning()) { - throw new IllegalStateException("execInContainer can only be used while the Container is running"); - } - - this.dockerClient - .execCreateCmd(this.containerId) - .withCmd(command); - - logger().debug("Running \"exec\" command: " + String.join(" ", command)); - final ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(this.containerId) - .withAttachStdout(true).withAttachStderr(true).withCmd(command).exec(); - - final ToStringConsumer stdoutConsumer = new ToStringConsumer(); - final ToStringConsumer stderrConsumer = new ToStringConsumer(); - - FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); - callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer); - callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer); - - dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion(); - - final ExecResult result = new ExecResult( - stdoutConsumer.toString(outputCharset), - stderrConsumer.toString(outputCharset)); - - logger().trace("stdout: " + result.getStdout()); - logger().trace("stderr: " + result.getStderr()); - return result; + return ExecInContainerPattern.execInContainer(getContainerInfo(), outputCharset, command); } /** @@ -1062,8 +981,11 @@ public SELF withCreateContainerCmdModifier(Consumer modifier /** * Convenience class with access to non-public members of GenericContainer. + * + * @deprecated use {@link org.testcontainers.containers.wait.strategy.AbstractWaitStrategy} */ - public static abstract class AbstractWaitStrategy implements WaitStrategy { + @Deprecated + public static abstract class AbstractWaitStrategy extends org.testcontainers.containers.wait.strategy.AbstractWaitStrategy implements WaitStrategy { protected GenericContainer container; @NonNull diff --git a/core/src/main/java/org/testcontainers/containers/wait/HostPortWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/HostPortWaitStrategy.java index b64535503c0..e208b80345d 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/HostPortWaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/HostPortWaitStrategy.java @@ -1,61 +1,31 @@ package org.testcontainers.containers.wait; import lombok.extern.slf4j.Slf4j; -import org.rnorth.ducttape.TimeoutException; -import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.internal.ExternalPortListeningCheck; -import org.testcontainers.containers.wait.internal.InternalCommandPortListeningCheck; -import java.util.List; -import java.util.Set; -import java.util.concurrent.Callable; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; +import java.time.Duration; /** * Waits until a socket connection can be established on a port exposed or mapped by the container. * * @author richardnorth + * + * @deprecated Use {@link org.testcontainers.containers.wait.strategy.HostPortWaitStrategy} */ +@Deprecated @Slf4j public class HostPortWaitStrategy extends GenericContainer.AbstractWaitStrategy { + private org.testcontainers.containers.wait.strategy.HostPortWaitStrategy delegateStrategy = new org.testcontainers.containers.wait.strategy.HostPortWaitStrategy(); + @Override protected void waitUntilReady() { - final Set externalLivenessCheckPorts = getLivenessCheckPorts(); - if (externalLivenessCheckPorts.isEmpty()) { - log.debug("Liveness check ports of {} is empty. Not waiting.", container.getContainerName()); - return; - } - - @SuppressWarnings("unchecked") - List exposedPorts = container.getExposedPorts(); - - final Set internalPorts = getInternalPorts(externalLivenessCheckPorts, exposedPorts); - - Callable internalCheck = new InternalCommandPortListeningCheck(container, internalPorts); - - Callable externalCheck = new ExternalPortListeningCheck(container, externalLivenessCheckPorts); - - try { - Unreliables.retryUntilTrue((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, () -> { - return getRateLimiter().getWhenReady(() -> internalCheck.call() && externalCheck.call()); - }); - - } catch (TimeoutException e) { - throw new ContainerLaunchException("Timed out waiting for container port to open (" + - container.getContainerIpAddress() + - " ports: " + - externalLivenessCheckPorts + - " should be listening)"); - } + delegateStrategy.waitUntilReady(this.waitStrategyTarget); } - private Set getInternalPorts(Set externalLivenessCheckPorts, List exposedPorts) { - return exposedPorts.stream() - .filter(it -> externalLivenessCheckPorts.contains(container.getMappedPort(it))) - .collect(Collectors.toSet()); + @Override + public WaitStrategy withStartupTimeout(Duration startupTimeout) { + delegateStrategy.withStartupTimeout(startupTimeout); + return super.withStartupTimeout(startupTimeout); } } diff --git a/core/src/main/java/org/testcontainers/containers/wait/HttpWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/HttpWaitStrategy.java index bc0aacb00ce..d27133c499d 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/HttpWaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/HttpWaitStrategy.java @@ -1,44 +1,20 @@ package org.testcontainers.containers.wait; -import com.google.common.base.Strings; -import com.google.common.io.BaseEncoding; -import org.rnorth.ducttape.TimeoutException; -import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.util.concurrent.TimeUnit; import java.util.function.Predicate; -import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; - /** * Waits until an HTTP(S) endpoint returns a given status code. * * @author Pete Cornish {@literal } + * + * @deprecated Use {@link org.testcontainers.containers.wait.strategy.HttpWaitStrategy} */ +@Deprecated public class HttpWaitStrategy extends GenericContainer.AbstractWaitStrategy { - /** - * Authorization HTTP header. - */ - private static final String HEADER_AUTHORIZATION = "Authorization"; - /** - * Basic Authorization scheme prefix. - */ - private static final String AUTH_BASIC = "Basic "; - - private String path = "/"; - private int statusCode = HttpURLConnection.HTTP_OK; - private boolean tlsEnabled; - private String username; - private String password; - private Predicate responsePredicate; + private org.testcontainers.containers.wait.strategy.HttpWaitStrategy delegateStrategy = new org.testcontainers.containers.wait.strategy.HttpWaitStrategy(); /** * Waits for the given status code. @@ -47,7 +23,7 @@ public class HttpWaitStrategy extends GenericContainer.AbstractWaitStrategy { * @return this */ public HttpWaitStrategy forStatusCode(int statusCode) { - this.statusCode = statusCode; + delegateStrategy.forStatusCode(statusCode); return this; } @@ -58,7 +34,7 @@ public HttpWaitStrategy forStatusCode(int statusCode) { * @return this */ public HttpWaitStrategy forPath(String path) { - this.path = path; + delegateStrategy.forPath(path); return this; } @@ -68,7 +44,7 @@ public HttpWaitStrategy forPath(String path) { * @return this */ public HttpWaitStrategy usingTls() { - this.tlsEnabled = true; + delegateStrategy.usingTls(); return this; } @@ -80,8 +56,7 @@ public HttpWaitStrategy usingTls() { * @return this */ public HttpWaitStrategy withBasicCredentials(String username, String password) { - this.username = username; - this.password = password; + delegateStrategy.withBasicCredentials(username, password); return this; } @@ -91,105 +66,12 @@ public HttpWaitStrategy withBasicCredentials(String username, String password) { * @return this */ public HttpWaitStrategy forResponsePredicate(Predicate responsePredicate) { - this.responsePredicate = responsePredicate; + delegateStrategy.forResponsePredicate(responsePredicate); return this; } @Override protected void waitUntilReady() { - final Integer livenessCheckPort = getLivenessCheckPort(); - if (null == livenessCheckPort) { - logger().warn("No exposed ports or mapped ports - cannot wait for status"); - return; - } - - final String uri = buildLivenessUri(livenessCheckPort).toString(); - logger().info("Waiting for {} seconds for URL: {}", startupTimeout.getSeconds(), uri); - - // try to connect to the URL - try { - retryUntilSuccess((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, () -> { - getRateLimiter().doWhenReady(() -> { - try { - final HttpURLConnection connection = (HttpURLConnection) new URL(uri).openConnection(); - - // authenticate - if (!Strings.isNullOrEmpty(username)) { - connection.setRequestProperty(HEADER_AUTHORIZATION, buildAuthString(username, password)); - connection.setUseCaches(false); - } - - connection.setRequestMethod("GET"); - connection.connect(); - - if (statusCode != connection.getResponseCode()) { - throw new RuntimeException(String.format("HTTP response code was: %s", - connection.getResponseCode())); - } - - if(responsePredicate != null) { - String responseBody = getResponseBody(connection); - if(!responsePredicate.test(responseBody)) { - throw new RuntimeException(String.format("Response: %s did not match predicate", - responseBody)); - } - } - - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - return true; - }); - - } catch (TimeoutException e) { - throw new ContainerLaunchException(String.format( - "Timed out waiting for URL to be accessible (%s should return HTTP %s)", uri, statusCode)); - } - } - - /** - * Build the URI on which to check if the container is ready. - * - * @param livenessCheckPort the liveness port - * @return the liveness URI - */ - private URI buildLivenessUri(int livenessCheckPort) { - final String scheme = (tlsEnabled ? "https" : "http") + "://"; - final String host = container.getContainerIpAddress(); - - final String portSuffix; - if ((tlsEnabled && 443 == livenessCheckPort) || (!tlsEnabled && 80 == livenessCheckPort)) { - portSuffix = ""; - } else { - portSuffix = ":" + String.valueOf(livenessCheckPort); - } - - return URI.create(scheme + host + portSuffix + path); - } - - /** - * @param username the username - * @param password the password - * @return a basic authentication string for the given credentials - */ - private String buildAuthString(String username, String password) { - return AUTH_BASIC + BaseEncoding.base64().encode((username + ":" + password).getBytes()); - } - - private String getResponseBody(HttpURLConnection connection) throws IOException { - BufferedReader reader; - if (200 <= connection.getResponseCode() && connection.getResponseCode() <= 299) { - reader = new BufferedReader(new InputStreamReader((connection.getInputStream()))); - } else { - reader = new BufferedReader(new InputStreamReader((connection.getErrorStream()))); - } - - StringBuilder builder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - builder.append(line); - } - return builder.toString(); + delegateStrategy.waitUntilReady(this.waitStrategyTarget); } } diff --git a/core/src/main/java/org/testcontainers/containers/wait/LogMessageWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/LogMessageWaitStrategy.java index 404731136d2..a21b6b66a59 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/LogMessageWaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/LogMessageWaitStrategy.java @@ -1,44 +1,29 @@ package org.testcontainers.containers.wait; -import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.output.OutputFrame; -import org.testcontainers.containers.output.WaitingConsumer; - -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Predicate; /** * Waits until containers logs expected content. + * + * @deprecated Use {@link org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy} */ +@Deprecated public class LogMessageWaitStrategy extends GenericContainer.AbstractWaitStrategy { - private String regEx; - private int times = 1; + private org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy delegateWaitStrategy = new org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy(); @Override protected void waitUntilReady() { - WaitingConsumer waitingConsumer = new WaitingConsumer(); - container.followOutput(waitingConsumer); - - Predicate waitPredicate = outputFrame -> - outputFrame.getUtf8String().matches(regEx); - - try { - waitingConsumer.waitUntil(waitPredicate, startupTimeout.getSeconds(), TimeUnit.SECONDS, times); - } catch (TimeoutException e) { - throw new ContainerLaunchException("Timed out waiting for log output matching '" + regEx + "'"); - } + delegateWaitStrategy.waitUntilReady(this.waitStrategyTarget); } public LogMessageWaitStrategy withRegEx(String regEx) { - this.regEx = regEx; + delegateWaitStrategy.withRegEx(regEx); return this; } public LogMessageWaitStrategy withTimes(int times) { - this.times = times; + delegateWaitStrategy.withTimes(times); return this; } } diff --git a/core/src/main/java/org/testcontainers/containers/wait/Wait.java b/core/src/main/java/org/testcontainers/containers/wait/Wait.java index 4a6717df36b..ac96d0b7de5 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/Wait.java +++ b/core/src/main/java/org/testcontainers/containers/wait/Wait.java @@ -6,7 +6,10 @@ * Convenience class with logic for building common {@link WaitStrategy} instances. * * @author Pete Cornish {@literal } + * + * @deprecated Use {@link org.testcontainers.containers.wait.strategy.Wait} */ +@Deprecated public class Wait { /** * Convenience method to return the default WaitStrategy. @@ -51,4 +54,15 @@ public static HttpWaitStrategy forHttps(String path) { return forHttp(path) .usingTls(); } + + /** + * Convenience method to return a WaitStrategy for log messages. + * + * @param regex the regex pattern to check for + * @param times the number of times the pattern is expected + * @return LogMessageWaitStrategy + */ + public static LogMessageWaitStrategy forLogMessage(String regex, int times) { + return new LogMessageWaitStrategy().withRegEx(regex).withTimes(times); + } } diff --git a/core/src/main/java/org/testcontainers/containers/wait/WaitAllStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/WaitAllStrategy.java index b4bf27ef98b..7e3032cd76d 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/WaitAllStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/WaitAllStrategy.java @@ -10,7 +10,10 @@ /** * Wait strategy that waits for a number of other strategies to pass in series. + * + * @deprecated Use {@link org.testcontainers.containers.wait.strategy.WaitAllStrategy} */ +@Deprecated public class WaitAllStrategy implements WaitStrategy { private final List strategies = new ArrayList<>(); diff --git a/core/src/main/java/org/testcontainers/containers/wait/WaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/WaitStrategy.java index 871c314a88d..ee2c14d90ae 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/WaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/WaitStrategy.java @@ -1,6 +1,7 @@ package org.testcontainers.containers.wait; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import java.time.Duration; @@ -8,18 +9,30 @@ * Approach to determine whether a container is ready. * * @author Pete Cornish {@literal } + * + * @deprecated Use {@link org.testcontainers.containers.wait.strategy.WaitStrategy} */ -public interface WaitStrategy { +@Deprecated +public interface WaitStrategy extends org.testcontainers.containers.wait.strategy.WaitStrategy { /** * Wait until the container has started. * * @param container the container for which to wait */ - void waitUntilReady(GenericContainer container); + default void waitUntilReady(GenericContainer container) { + this.waitUntilReady((WaitStrategyTarget)container); + } /** * @param startupTimeout the duration for which to wait * @return this */ WaitStrategy withStartupTimeout(Duration startupTimeout); + + /** + * {@inheritDoc} + */ + default void waitUntilReady(WaitStrategyTarget waitStrategyTarget) { + //default method for backwards compatibility + } } diff --git a/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java b/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java index 65f6182b2bc..3283880b569 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java +++ b/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java @@ -1,7 +1,7 @@ package org.testcontainers.containers.wait.internal; import lombok.RequiredArgsConstructor; -import org.testcontainers.containers.Container; +import org.testcontainers.containers.ContainerState; import java.io.IOException; import java.net.Socket; @@ -13,12 +13,12 @@ */ @RequiredArgsConstructor public class ExternalPortListeningCheck implements Callable { - private final Container container; + private final ContainerState containerState; private final Set externalLivenessCheckPorts; @Override public Boolean call() { - String address = container.getContainerIpAddress(); + String address = containerState.getContainerIpAddress(); for (Integer externalPort : externalLivenessCheckPorts) { try { diff --git a/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java b/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java index 555bfe81437..2cf7092f1ae 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java +++ b/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java @@ -1,7 +1,8 @@ package org.testcontainers.containers.wait.internal; import lombok.RequiredArgsConstructor; -import org.testcontainers.containers.Container; +import org.testcontainers.containers.ExecInContainerPattern; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import java.util.Set; @@ -15,7 +16,7 @@ public class InternalCommandPortListeningCheck implements java.util.concurrent.C private static final String SUCCESS_MARKER = "TESTCONTAINERS_SUCCESS"; - private final Container container; + private final WaitStrategyTarget waitStrategyTarget; private final Set internalPorts; @Override @@ -36,7 +37,7 @@ private void tryPort(Integer internalPort) { for (String[] command : commands) { try { - if (container.execInContainer(command).getStdout().contains(SUCCESS_MARKER)) { + if (ExecInContainerPattern.execInContainer(waitStrategyTarget.getContainerInfo(), command).getStdout().contains(SUCCESS_MARKER)) { return; } } catch (Exception e) { diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/AbstractWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/AbstractWaitStrategy.java new file mode 100644 index 00000000000..e0d87578e98 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/AbstractWaitStrategy.java @@ -0,0 +1,65 @@ +package org.testcontainers.containers.wait.strategy; + +import lombok.NonNull; +import org.rnorth.ducttape.ratelimits.RateLimiter; +import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; + +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public abstract class AbstractWaitStrategy implements WaitStrategy { + + protected WaitStrategyTarget waitStrategyTarget; + + @NonNull + protected Duration startupTimeout = Duration.ofSeconds(60); + + private static final RateLimiter DOCKER_CLIENT_RATE_LIMITER = RateLimiterBuilder + .newBuilder() + .withRate(1, TimeUnit.SECONDS) + .withConstantThroughput() + .build(); + + /** + * Wait until the target has started. + * + * @param waitStrategyTarget the target of the WaitStrategy + */ + @Override + public void waitUntilReady(WaitStrategyTarget waitStrategyTarget) { + this.waitStrategyTarget = waitStrategyTarget; + waitUntilReady(); + } + + /** + * Wait until {@link #waitStrategyTarget} has started. + */ + protected abstract void waitUntilReady(); + + /** + * Set the duration of waiting time until container treated as started. + * + * @param startupTimeout timeout + * @return this + * @see WaitStrategy#waitUntilReady(WaitStrategyTarget) + */ + public WaitStrategy withStartupTimeout(Duration startupTimeout) { + this.startupTimeout = startupTimeout; + return this; + } + + /** + * @return the ports on which to check if the container is ready + */ + protected Set getLivenessCheckPorts() { + return waitStrategyTarget.getLivenessCheckPortNumbers(); + } + + /** + * @return the rate limiter to use + */ + protected RateLimiter getRateLimiter() { + return DOCKER_CLIENT_RATE_LIMITER; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java new file mode 100644 index 00000000000..ac506b4ed3b --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java @@ -0,0 +1,59 @@ +package org.testcontainers.containers.wait.strategy; + +import lombok.extern.slf4j.Slf4j; +import org.rnorth.ducttape.TimeoutException; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.wait.internal.ExternalPortListeningCheck; +import org.testcontainers.containers.wait.internal.InternalCommandPortListeningCheck; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Waits until a socket connection can be established on a port exposed or mapped by the container. + * + * @author richardnorth + */ +@Slf4j +public class HostPortWaitStrategy extends AbstractWaitStrategy { + + @Override + protected void waitUntilReady() { + final Set externalLivenessCheckPorts = getLivenessCheckPorts(); + if (externalLivenessCheckPorts.isEmpty()) { + log.debug("Liveness check ports of {} is empty. Not waiting.", waitStrategyTarget.getContainerInfo().getName()); + return; + } + + @SuppressWarnings("unchecked") + List exposedPorts = waitStrategyTarget.getExposedPorts(); + + final Set internalPorts = getInternalPorts(externalLivenessCheckPorts, exposedPorts); + + Callable internalCheck = new InternalCommandPortListeningCheck(waitStrategyTarget, internalPorts); + + Callable externalCheck = new ExternalPortListeningCheck(waitStrategyTarget, externalLivenessCheckPorts); + + try { + Unreliables.retryUntilTrue((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, + () -> getRateLimiter().getWhenReady(() -> internalCheck.call() && externalCheck.call())); + + } catch (TimeoutException e) { + throw new ContainerLaunchException("Timed out waiting for container port to open (" + + waitStrategyTarget.getContainerIpAddress() + + " ports: " + + externalLivenessCheckPorts + + " should be listening)"); + } + } + + private Set getInternalPorts(Set externalLivenessCheckPorts, List exposedPorts) { + return exposedPorts.stream() + .filter(it -> externalLivenessCheckPorts.contains(waitStrategyTarget.getMappedPort(it))) + .collect(Collectors.toSet()); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java new file mode 100644 index 00000000000..bf63e9708d5 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java @@ -0,0 +1,195 @@ +package org.testcontainers.containers.wait.strategy; + +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; +import lombok.extern.slf4j.Slf4j; +import org.rnorth.ducttape.TimeoutException; +import org.testcontainers.containers.ContainerLaunchException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; + +@Slf4j +public class HttpWaitStrategy extends AbstractWaitStrategy { + + /** + * Authorization HTTP header. + */ + private static final String HEADER_AUTHORIZATION = "Authorization"; + + /** + * Basic Authorization scheme prefix. + */ + private static final String AUTH_BASIC = "Basic "; + + private String path = "/"; + private int statusCode = HttpURLConnection.HTTP_OK; + private boolean tlsEnabled; + private String username; + private String password; + private Predicate responsePredicate; + + /** + * Waits for the given status code. + * + * @param statusCode the expected status code + * @return this + */ + public HttpWaitStrategy forStatusCode(int statusCode) { + this.statusCode = statusCode; + return this; + } + + /** + * Waits for the given path. + * + * @param path the path to check + * @return this + */ + public HttpWaitStrategy forPath(String path) { + this.path = path; + return this; + } + + /** + * Indicates that the status check should use HTTPS. + * + * @return this + */ + public HttpWaitStrategy usingTls() { + this.tlsEnabled = true; + return this; + } + + /** + * Authenticate with HTTP Basic Authorization credentials. + * + * @param username the username + * @param password the password + * @return this + */ + public HttpWaitStrategy withBasicCredentials(String username, String password) { + this.username = username; + this.password = password; + return this; + } + + /** + * Waits for the response to pass the given predicate + * @param responsePredicate The predicate to test the response against + * @return this + */ + public HttpWaitStrategy forResponsePredicate(Predicate responsePredicate) { + this.responsePredicate = responsePredicate; + return this; + } + + @Override + protected void waitUntilReady() { + final String containerName = waitStrategyTarget.getContainerInfo().getName(); + final Set livenessCheckPorts = getLivenessCheckPorts(); + if (livenessCheckPorts == null || livenessCheckPorts.isEmpty()) { + log.warn("{}: No exposed ports or mapped ports - cannot wait for status", containerName); + return; + } + + final Integer livenessCheckPort = livenessCheckPorts.iterator().next(); + final String uri = buildLivenessUri(livenessCheckPort).toString(); + log.info("{}: Waiting for {} seconds for URL: {}", containerName, startupTimeout.getSeconds(), uri); + + // try to connect to the URL + try { + retryUntilSuccess((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, () -> { + getRateLimiter().doWhenReady(() -> { + try { + final HttpURLConnection connection = (HttpURLConnection) new URL(uri).openConnection(); + + // authenticate + if (!Strings.isNullOrEmpty(username)) { + connection.setRequestProperty(HEADER_AUTHORIZATION, buildAuthString(username, password)); + connection.setUseCaches(false); + } + + connection.setRequestMethod("GET"); + connection.connect(); + + if (statusCode != connection.getResponseCode()) { + throw new RuntimeException(String.format("HTTP response code was: %s", + connection.getResponseCode())); + } + + if(responsePredicate != null) { + String responseBody = getResponseBody(connection); + if(!responsePredicate.test(responseBody)) { + throw new RuntimeException(String.format("Response: %s did not match predicate", + responseBody)); + } + } + + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + return true; + }); + + } catch (TimeoutException e) { + throw new ContainerLaunchException(String.format( + "Timed out waiting for URL to be accessible (%s should return HTTP %s)", uri, statusCode)); + } + } + + /** + * Build the URI on which to check if the container is ready. + * + * @param livenessCheckPort the liveness port + * @return the liveness URI + */ + private URI buildLivenessUri(int livenessCheckPort) { + final String scheme = (tlsEnabled ? "https" : "http") + "://"; + final String host = waitStrategyTarget.getContainerIpAddress(); + + final String portSuffix; + if ((tlsEnabled && 443 == livenessCheckPort) || (!tlsEnabled && 80 == livenessCheckPort)) { + portSuffix = ""; + } else { + portSuffix = ":" + String.valueOf(livenessCheckPort); + } + + return URI.create(scheme + host + portSuffix + path); + } + + /** + * @param username the username + * @param password the password + * @return a basic authentication string for the given credentials + */ + private String buildAuthString(String username, String password) { + return AUTH_BASIC + BaseEncoding.base64().encode((username + ":" + password).getBytes()); + } + + private String getResponseBody(HttpURLConnection connection) throws IOException { + BufferedReader reader; + if (200 <= connection.getResponseCode() && connection.getResponseCode() <= 299) { + reader = new BufferedReader(new InputStreamReader((connection.getInputStream()))); + } else { + reader = new BufferedReader(new InputStreamReader((connection.getErrorStream()))); + } + + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + return builder.toString(); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java new file mode 100644 index 00000000000..a25b1a86d8e --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java @@ -0,0 +1,43 @@ +package org.testcontainers.containers.wait.strategy; + +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.WaitingConsumer; +import org.testcontainers.utility.LogUtils; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; + +public class LogMessageWaitStrategy extends AbstractWaitStrategy { + + private String regEx; + + private int times = 1; + + @Override + protected void waitUntilReady() { + WaitingConsumer waitingConsumer = new WaitingConsumer(); + LogUtils.followOutput(DockerClientFactory.instance().client(), waitStrategyTarget.getContainerId(), waitingConsumer); + + Predicate waitPredicate = outputFrame -> + outputFrame.getUtf8String().matches(regEx); + + try { + waitingConsumer.waitUntil(waitPredicate, startupTimeout.getSeconds(), TimeUnit.SECONDS, times); + } catch (TimeoutException e) { + throw new ContainerLaunchException("Timed out waiting for log output matching '" + regEx + "'"); + } + } + + public LogMessageWaitStrategy withRegEx(String regEx) { + this.regEx = regEx; + return this; + } + + public LogMessageWaitStrategy withTimes(int times) { + this.times = times; + return this; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java new file mode 100644 index 00000000000..789927060b3 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java @@ -0,0 +1,64 @@ +package org.testcontainers.containers.wait.strategy; + +import java.net.HttpURLConnection; + +/** + * Convenience class with logic for building common {@link WaitStrategy} instances. + * + */ +public class Wait { + /** + * Convenience method to return the default WaitStrategy. + * + * @return a WaitStrategy + */ + public static WaitStrategy defaultWaitStrategy() { + return forListeningPort(); + } + + /** + * Convenience method to return a WaitStrategy for an exposed or mapped port. + * + * @return the WaitStrategy + * @see HostPortWaitStrategy + */ + public static HostPortWaitStrategy forListeningPort() { + return new HostPortWaitStrategy(); + } + + /** + * Convenience method to return a WaitStrategy for an HTTP endpoint. + * + * @param path the path to check + * @return the WaitStrategy + * @see HttpWaitStrategy + */ + public static HttpWaitStrategy forHttp(String path) { + return new HttpWaitStrategy() + .forPath(path) + .forStatusCode(HttpURLConnection.HTTP_OK); + } + + /** + * Convenience method to return a WaitStrategy for an HTTPS endpoint. + * + * @param path the path to check + * @return the WaitStrategy + * @see HttpWaitStrategy + */ + public static HttpWaitStrategy forHttps(String path) { + return forHttp(path) + .usingTls(); + } + + /** + * Convenience method to return a WaitStrategy for log messages. + * + * @param regex the regex pattern to check for + * @param times the number of times the pattern is expected + * @return LogMessageWaitStrategy + */ + public static LogMessageWaitStrategy forLogMessage(String regex, int times) { + return new LogMessageWaitStrategy().withRegEx(regex).withTimes(times); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java new file mode 100644 index 00000000000..1cc76c0747c --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java @@ -0,0 +1,34 @@ +package org.testcontainers.containers.wait.strategy; + +import org.rnorth.ducttape.timeouts.Timeouts; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class WaitAllStrategy implements WaitStrategy { + + private final List strategies = new ArrayList<>(); + private Duration timeout = Duration.ofSeconds(30); + + @Override + public void waitUntilReady(WaitStrategyTarget waitStrategyTarget) { + Timeouts.doWithTimeout((int) timeout.toMillis(), TimeUnit.MILLISECONDS, () -> { + for (WaitStrategy strategy : strategies) { + strategy.waitUntilReady(waitStrategyTarget); + } + }); + } + + public WaitAllStrategy withStrategy(WaitStrategy strategy) { + this.strategies.add(strategy); + return this; + } + + @Override + public WaitStrategy withStartupTimeout(Duration startupTimeout) { + this.timeout = startupTimeout; + return this; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java new file mode 100644 index 00000000000..25703174502 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java @@ -0,0 +1,10 @@ +package org.testcontainers.containers.wait.strategy; + +import java.time.Duration; + +public interface WaitStrategy { + + void waitUntilReady(WaitStrategyTarget waitStrategyTarget); + + WaitStrategy withStartupTimeout(Duration startupTimeout); +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java new file mode 100644 index 00000000000..49f88fb7711 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java @@ -0,0 +1,19 @@ +package org.testcontainers.containers.wait.strategy; + +import org.testcontainers.containers.ContainerState; + +import java.util.Set; +import java.util.stream.Collectors; + +public interface WaitStrategyTarget extends ContainerState { + + /** + * @return the ports on which to check if the container is ready + */ + default Set getLivenessCheckPortNumbers() { + final Set result = getExposedPorts().stream() + .map(this::getMappedPort).distinct().collect(Collectors.toSet()); + result.addAll(getBoundPortNumbers()); + return result; + } +} diff --git a/core/src/main/java/org/testcontainers/utility/LogUtils.java b/core/src/main/java/org/testcontainers/utility/LogUtils.java index 75a78002366..8a17be9f65d 100644 --- a/core/src/main/java/org/testcontainers/utility/LogUtils.java +++ b/core/src/main/java/org/testcontainers/utility/LogUtils.java @@ -35,4 +35,8 @@ public void followOutput(DockerClient dockerClient, String containerId, cmd.exec(callback); } + + public void followOutput(DockerClient dockerClient, String containerId, Consumer consumer) { + followOutput(dockerClient, containerId, consumer, STDOUT, STDERR); + } } diff --git a/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java b/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java index 231942bf568..69bf9d06ce7 100644 --- a/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java +++ b/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java @@ -5,7 +5,7 @@ import org.junit.Before; import org.junit.Test; import org.rnorth.visibleassertions.VisibleAssertions; -import org.testcontainers.containers.Container; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import java.net.ServerSocket; @@ -18,7 +18,7 @@ public class ExternalPortListeningCheckTest { private ServerSocket listeningSocket1; private ServerSocket listeningSocket2; private ServerSocket nonListeningSocket; - private Container mockContainer; + private WaitStrategyTarget mockContainer; @Before public void setUp() throws Exception { @@ -28,7 +28,7 @@ public void setUp() throws Exception { nonListeningSocket = new ServerSocket(0); nonListeningSocket.close(); - mockContainer = mock(Container.class); + mockContainer = mock(WaitStrategyTarget.class); when(mockContainer.getContainerIpAddress()).thenReturn("127.0.0.1"); } @@ -68,4 +68,4 @@ public void tearDown() throws Exception { listeningSocket1.close(); listeningSocket2.close(); } -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java b/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java index 8024eac22a4..7f8b5d79953 100644 --- a/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java +++ b/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java @@ -1,9 +1,11 @@ package org.testcontainers.junit; import com.github.dockerjava.api.model.Network; -import org.jetbrains.annotations.NotNull; -import org.junit.*; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.utility.TestEnvironment; @@ -11,8 +13,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static org.hamcrest.CoreMatchers.is; @@ -39,9 +39,6 @@ public static void checkVersion() { public void simpleTest() { Jedis jedis = new Jedis(getEnvironment().getServiceHost("redis_1", REDIS_PORT), getEnvironment().getServicePort("redis_1", REDIS_PORT)); - // TODO: remove following resolution of #160 - Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, getLivenessCheck(jedis)); - jedis.incr("test"); jedis.incr("test"); jedis.incr("test"); @@ -54,9 +51,6 @@ public void secondTest() { // used in manual checking for cleanup in between tests Jedis jedis = new Jedis(getEnvironment().getServiceHost("redis_1", REDIS_PORT), getEnvironment().getServicePort("redis_1", REDIS_PORT)); - // TODO: remove following resolution of #160 - Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, getLivenessCheck(jedis)); - jedis.incr("test"); jedis.incr("test"); jedis.incr("test"); @@ -82,13 +76,4 @@ private List findAllNetworks() { .sorted() .collect(Collectors.toList()); } - - @NotNull - private Callable getLivenessCheck(Jedis jedis) { - return () -> { - jedis.connect(); - jedis.ping(); - return true; - }; - } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java index 48c5b0db15f..cdeea00d201 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java @@ -1,29 +1,29 @@ package org.testcontainers.junit; -import com.google.common.util.concurrent.Uninterruptibles; import org.junit.Assume; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.ContainerState; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; import org.testcontainers.utility.TestEnvironment; -import java.io.BufferedReader; import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.Socket; -import java.util.concurrent.TimeUnit; +import java.util.Arrays; +import java.util.Objects; -import static org.rnorth.visibleassertions.VisibleAssertions.info; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.rnorth.visibleassertions.VisibleAssertions.assertNotNull; +import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; /** * Created by rnorth on 11/06/2016. */ public class DockerComposePassthroughTest { + private final TestWaitStrategy waitStrategy = new TestWaitStrategy(); + @BeforeClass public static void checkVersion() { Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); @@ -31,31 +31,35 @@ public static void checkVersion() { @Rule public DockerComposeContainer compose = - new DockerComposeContainer(new File("src/test/resources/v2-compose-test-passthrough.yml")) - .withEnv("foo", "bar") - .withExposedService("alpine_1", 3000); - - @Test(timeout = 30_000) - public void testEnvVar() throws IOException { - BufferedReader br = Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { - Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); - - Socket socket = new Socket(compose.getServiceHost("alpine_1", 3000), compose.getServicePort("alpine_1", 3000)); - return new BufferedReader(new InputStreamReader(socket.getInputStream())); - }); - - Unreliables.retryUntilTrue(10, TimeUnit.SECONDS, () -> { - while (br.ready()) { - String line = br.readLine(); - if (line.contains("bar=bar")) { - pass("Mapped environment variable was found"); - return true; - } - } - info("Mapped environment variable was not found yet - process probably not ready"); - Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); - return false; - }); + new DockerComposeContainer(new File("src/test/resources/v2-compose-test-passthrough.yml")) + .withEnv("foo", "bar") + .withExposedService("alpine_1", 3000, waitStrategy); + + + @Test + public void testContainerInstanceProperties() { + final ContainerState container = waitStrategy.getContainer(); + + //check environment variable was set + assertThat("Environment variable set correctly", Arrays.asList(Objects.requireNonNull(container.getContainerInfo() + .getConfig().getEnv())), hasItem("bar=bar")); + + //check other container properties + assertNotNull("Container id is not null", container.getContainerId()); + assertNotNull("Port mapped", container.getMappedPort(3000)); + assertThat("Exposed Ports", container.getExposedPorts(), hasItem(3000)); + + } + + /* + * WaitStrategy is the only class that has access to the DockerComposeServiceInstance reference + * Using a custom WaitStrategy to expose the reference for testability + */ + class TestWaitStrategy extends HostPortWaitStrategy { + @SuppressWarnings("unchecked") + public ContainerState getContainer() { + return this.waitStrategyTarget; + } } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java new file mode 100644 index 00000000000..7724b048b69 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java @@ -0,0 +1,65 @@ +package org.testcontainers.junit; + +import org.junit.Test; +import org.junit.runner.Description; +import org.rnorth.visibleassertions.VisibleAssertions; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.File; +import java.time.Duration; + +public class DockerComposeWaitStrategyTest { + + private static final int REDIS_PORT = 6379; + + @Test + public void testWaitOnListeningPort() { + final DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()); + + try { + environment.starting(Description.createTestDescription(Object.class, "name")); + VisibleAssertions.pass("Docker compose should start after waiting for listening port"); + } catch (RuntimeException e) { + VisibleAssertions.fail("Docker compose should start after waiting for listening port with failed with: " + e); + } + } + + @Test + public void testWaitOnMultipleStrategiesPassing() { + final DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()) + .withExposedService("db_1", 3306, Wait.forLogMessage(".*ready for connections.*\\s", 1)) + .withTailChildContainers(true); + + try { + environment.starting(Description.createTestDescription(Object.class, "name")); + VisibleAssertions.pass("Docker compose should start after waiting for listening port"); + } catch (RuntimeException e) { + VisibleAssertions.fail("Docker compose should start after waiting for listening port with failed with: " + e); + } + } + + @Test + public void testWaitingFails() { + final DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withExposedService("redis_1", REDIS_PORT, Wait.forHttp("/test").withStartupTimeout(Duration.ofSeconds(10))); + VisibleAssertions.assertThrows("waiting on an invalid http path times out", + RuntimeException.class, + () -> environment.starting(Description.createTestDescription(Object.class, "name"))); + } + + @Test + public void testWaitOnOneOfMultipleStrategiesFailing() { + final DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(10))) + .waitingFor("db_1", Wait.forLogMessage(".*test test test.*\\s", 1).withStartupTimeout(Duration.ofSeconds(10))) + .withTailChildContainers(true); + + VisibleAssertions.assertThrows("waiting on one failing strategy to time out", + RuntimeException.class, + () -> environment.starting(Description.createTestDescription(Object.class, "name"))); + } + +} diff --git a/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java new file mode 100644 index 00000000000..840d8864a9b --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java @@ -0,0 +1,30 @@ +package org.testcontainers.junit.wait.strategy; + +import org.junit.ClassRule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.time.Duration; + +import static org.rnorth.visibleassertions.VisibleAssertions.pass; + +/** + * Test wait strategy with overloaded waitingFor methods. + * Other implementations of WaitStrategy are tested through backwards compatible wait strategy tests + */ +public class HostPortWaitStrategyTest { + + private static final String IMAGE_NAME = "alpine:latest"; + + @ClassRule + public static GenericContainer container = new GenericContainer(IMAGE_NAME).withExposedPorts() + .withCommand("sh", "-c", "while true; do nc -lp 8080; done") + .withExposedPorts(8080) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(10))); + + @Test + public void testWaiting() { + pass("Container starts after waiting"); + } +} diff --git a/core/src/test/resources/v2-compose-test-passthrough.yml b/core/src/test/resources/v2-compose-test-passthrough.yml index 6b0cd807158..b52f2d28b0e 100644 --- a/core/src/test/resources/v2-compose-test-passthrough.yml +++ b/core/src/test/resources/v2-compose-test-passthrough.yml @@ -3,4 +3,4 @@ services: alpine: build: compose-dockerfile environment: - bar: ${foo} \ No newline at end of file + bar: ${foo} diff --git a/docs/usage/docker_compose.md b/docs/usage/docker_compose.md index 2da864f16ca..f786992b07c 100644 --- a/docs/usage/docker_compose.md +++ b/docs/usage/docker_compose.md @@ -54,6 +54,61 @@ String redisUrl = environment.getServiceHost("redis_1", REDIS_PORT) environment.getServicePort("redis_1", REDIS_PORT); ``` +## Startup timeout +Ordinarily Testcontainers will wait for up to 60 seconds for each exposed container's first mapped network port to start listening. + +This simple measure provides a basic check whether a container is ready for use. + +There are overloaded `withExposedService` methods that take a `WaitStrategy` so you can specify a timeout strategy per container. + +### Waiting for startup examples + +Waiting for exposed port to start listening: +```java +@ClassRule +public static DockerComposeContainer environment = + new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withStartupTimeout(Duration.ofSeconds(30)) + .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()); +``` + +Wait for arbitrary status code on an HTTPS endpoint: +```java +@ClassRule +public static DockerComposeContainer environment = + new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withStartupTimeout(Duration.ofSeconds(30)) + .withExposedService("elasticsearch_1", ELASTICSEARCH_PORT, + Wait.forHttp("/all") + .forStatusCode(301) + .usingTls()); +``` + +Separate wait strategies for each container: +```java +@ClassRule +public static DockerComposeContainer environment = + new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withStartupTimeout(Duration.ofSeconds(30)) + .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()) + .withExposedService("elasticsearch_1", ELASTICSEARCH_PORT, + Wait.forHttp("/all") + .forStatusCode(301) + .usingTls()); +``` + +Alternatively, you can use `waitingFor(serviceName, waitStrategy)`, +for example if you need to wait on a log message from a service, but don't need to expose a port. + +```java +@ClassRule +public static DockerComposeContainer environment = + new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + .withStartupTimeout(Duration.ofSeconds(30)) + .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()) + .waitingFor("db_1", Wait.forLogMessage("started", 1)); +``` + ## Using private repositories in Docker compose When Docker Compose is used in container mode (not local), it's needs to be made aware of Docker settings for private repositories. By default, those setting are located in `$HOME/.docker/config.json`. @@ -93,4 +148,4 @@ To work around this problem, create `config.json` in separate location with real "credsStore" : "osxkeychain" } ``` -and specify the location to Testcontainers using any of the two first methods from above. \ No newline at end of file +and specify the location to Testcontainers using any of the two first methods from above. diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy index f90d7f7a4a7..b952dd1c5dc 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy @@ -3,6 +3,7 @@ package org.testcontainers.spock import org.apache.http.client.methods.HttpGet import org.apache.http.impl.client.HttpClientBuilder import org.testcontainers.containers.DockerComposeContainer +import org.testcontainers.containers.wait.strategy.Wait import spock.lang.Specification @Testcontainers @@ -10,7 +11,7 @@ class ComposeContainerIT extends Specification { DockerComposeContainer composeContainer = new DockerComposeContainer( new File("src/test/resources/docker-compose.yml")) - .withExposedService("whoami_1", 80) + .withExposedService("whoami_1", 80, Wait.forHttp("/")) String host diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy index 091ac4cb044..dd2ce9f5b3c 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy @@ -3,6 +3,7 @@ package org.testcontainers.spock import org.apache.http.client.methods.HttpGet import org.apache.http.impl.client.HttpClientBuilder import org.testcontainers.containers.DockerComposeContainer +import org.testcontainers.containers.wait.strategy.Wait import spock.lang.Shared import spock.lang.Specification @@ -12,7 +13,7 @@ class SharedComposeContainerIT extends Specification { @Shared DockerComposeContainer composeContainer = new DockerComposeContainer( new File("src/test/resources/docker-compose.yml")) - .withExposedService("whoami_1", 80) + .withExposedService("whoami_1", 80, Wait.forHttp("/")) String host