-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support for WaitStrategy on DockerComposeContainer - alternative approach #600
Changes from 8 commits
de9d5fe
f53f3d1
54e510a
a4c5230
8bdce28
1af5218
8eb30ca
d0bac10
d9e885c
b11120d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 org.slf4j.Logger; | ||
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; | ||
@Getter | ||
private final Logger logger; | ||
private Map<Integer, Integer> mappedPorts = new HashMap<>(); | ||
@Getter | ||
private List<Integer> exposedPorts = new ArrayList<>(); | ||
private InspectContainerResponse containerInfo; | ||
|
||
ComposeServiceWaitStrategyTarget(Container container, GenericContainer proxyContainer, | ||
Logger logger, Map<Integer, Integer> mappedPorts) { | ||
this.container = container; | ||
|
||
this.proxyContainer = proxyContainer; | ||
this.logger = logger; | ||
|
||
if (mappedPorts != null) { | ||
this.mappedPorts.putAll(mappedPorts); | ||
this.exposedPorts.addAll(this.mappedPorts.keySet()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do you store There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the I didn't want to create a new There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Also, maybe we should make There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, sorry, obviously
Isn't that functionally the same as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it is :) Both are equally fine to me since we're talking about collections with just a few elements There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ah, I've just realised why this won't work. default Set<Integer> getExposedPortNumbers() {
return getExposedPorts().stream()
.map(this::getMappedPort)
.collect(Collectors.toSet());
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just override (implement) both in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, I didn't notice that! Yes, sure! P.S. I checked the implementation and it seems it is only used in Let's just remove this method and use it's implementation in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Despite the similar names, I think e.g. I took the name from a private method in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I deleted the original comment, because I was starting to confuse myself. |
||
} | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
*/ | ||
@Override | ||
public Integer getMappedPort(int originalPort) { | ||
return this.proxyContainer.getMappedPort(this.mappedPorts.get(originalPort)); | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
*/ | ||
@Override | ||
public String getContainerId() { | ||
return this.container.getId(); | ||
} | ||
|
||
@Override | ||
public InspectContainerResponse getContainerInfo() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can remove this method and use: @Getter(lazy=true)
private InspectContainerResponse containerInfo = DockerClientFactory.instance().client().inspectContainerCmd(getContainerId()).exec(); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it will also make it thread safe |
||
if(containerInfo == null) { | ||
containerInfo = DockerClientFactory.instance().client().inspectContainerCmd(getContainerId()).exec(); | ||
} | ||
return containerInfo; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SELF extends Container<SELF>> extends LinkableContainer { | ||
public interface Container<SELF extends Container<SELF>> extends LinkableContainer, ContainerState { | ||
|
||
/** | ||
* @return a reference to this container instance, cast to the expected generic type. | ||
|
@@ -32,6 +32,11 @@ default SELF self() { | |
return (SELF) this; | ||
} | ||
|
||
@Override | ||
default String getContainerName() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why this change is needed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. since the method was protected, this is a breaking change anyway (making it public) |
||
return ContainerState.super.getContainerName(); | ||
} | ||
|
||
/** | ||
* Class to hold results from a "docker exec" command. Note that, due to the limitations of the | ||
* docker API, there's no easy way to get the result code from the process we ran. | ||
|
@@ -131,7 +136,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 +288,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 +302,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 +325,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); | ||
|
||
/** | ||
* <b>Resolve</b> Docker image and set it. | ||
* | ||
|
@@ -386,7 +357,9 @@ default Integer getFirstMappedPort() { | |
* | ||
* @param consumer consumer that the frames should be sent to | ||
*/ | ||
void followOutput(Consumer<OutputFrame> consumer); | ||
default void followOutput(Consumer<OutputFrame> 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 +368,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<OutputFrame> consumer, OutputFrame.OutputType... types); | ||
default void followOutput(Consumer<OutputFrame> consumer, OutputFrame.OutputType... types) { | ||
LogUtils.followOutput(DockerClientFactory.instance().client(), getContainerId(), consumer, types); | ||
} | ||
|
||
|
||
/** | ||
|
@@ -419,22 +394,15 @@ default Integer getFirstMappedPort() { | |
* Run a command inside a running container, as though using "docker exec", and interpreting | ||
* the output as UTF8. | ||
* <p> | ||
* @see #execInContainer(Charset, String...) | ||
* @see ExecInContainerPattern#execInContainer(com.github.dockerjava.api.command.InspectContainerResponse, org.slf4j.Logger, String...) | ||
*/ | ||
ExecResult execInContainer(String... command) | ||
throws UnsupportedOperationException, IOException, InterruptedException; | ||
|
||
/** | ||
* Run a command inside a running container, as though using "docker exec". | ||
* <p> | ||
* 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, org.slf4j.Logger, String...) | ||
*/ | ||
ExecResult execInContainer(Charset outputCharset, String... command) | ||
throws UnsupportedOperationException, IOException, InterruptedException; | ||
|
@@ -460,8 +428,6 @@ ExecResult execInContainer(Charset outputCharset, String... command) | |
*/ | ||
void copyFileFromContainer(String containerPath, String destinationPath) throws IOException, InterruptedException; | ||
|
||
List<Integer> getExposedPorts(); | ||
|
||
List<String> getPortBindings(); | ||
|
||
List<String> getExtraHosts(); | ||
|
@@ -496,17 +462,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<Integer> exposedPorts); | ||
|
||
void setPortBindings(List<String> portBindings); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
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 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 | ||
*/ | ||
Integer getMappedPort(int originalPort); | ||
|
||
/** | ||
* @return the exposed ports | ||
*/ | ||
List<Integer> getExposedPorts(); | ||
|
||
/** | ||
* @return the container exposed port numbers mapped to ports exposed on the docker host | ||
*/ | ||
default List<Integer> getExposedPortNumbers() { | ||
return getExposedPorts().stream() | ||
.map(this::getMappedPort) | ||
.collect(Collectors.toList()); | ||
} | ||
|
||
/** | ||
* @return the port bindings | ||
*/ | ||
default List<String> getPortBindings() { | ||
List<String> portBindings = new ArrayList<>(); | ||
final Ports hostPortBindings = this.getContainerInfo().getHostConfig().getPortBindings(); | ||
for (Map.Entry<ExposedPort, Ports.Binding[]> 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<Integer> 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 name of the container | ||
*/ | ||
default String getContainerName() { | ||
return getContainerInfo().getName(); | ||
} | ||
|
||
/** | ||
* @return the container info | ||
*/ | ||
InspectContainerResponse getContainerInfo(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please add
@NonNull
tomappedPorts
argument and usethis.mappedPorts = new HashMap(mappedPorts)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A
ComposeServiceWaitStrategyTarget
is created for every service returned bylistChildContainers()
inDockerComposeContainer
, which can return services with no exposed ports.for example:
and a test with
Only the redis container has exposed/mapped ports, so the
ComposeServiceWaitStrategyTarget
for thedb
service would have a null value formappedPorts
.I'll change the behaviour to only create a
ComposeServiceWaitStrategyTarget
for services that have been explicitly exposed.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should never accept null as a valid collection argument's value, that was the motivation :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now I think about use cases where waiting strategy doesn't use the ports at all but checks the logs for instance.
Current API doesn't allow that without exposing a port, right?
Should we maybe add
waitingFor("redis_1", Wait.forLogMessage())
method?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can add that method, but then we get into the problem of a
ComposeServiceWaitStrategyTarget
with no exposed ports, so the constructor would have to accept a null or empty value formappedPorts
, which would mean returning a empty list fromgetExposedPorts
.That goes against this idea:
It also means you could use
DockerComposeContainer
like this:or like this:
Is that slightly confusing for the user?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds absolutely fine to me - no exposed ports means an empty set.
Second example IMO doesn't look confusing, and makes even more sense because we're actually waiting for a service and not for the exposed port :)
Just
.withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort())
is a good alias/shortcut for it