no.mnemonic.commons.junit.docker.DockerResource Maven / Gradle / Ivy
Show all versions of junit-docker Show documentation
package no.mnemonic.commons.junit.docker;
import no.mnemonic.commons.utilities.ObjectUtils;
import no.mnemonic.commons.utilities.StringUtils;
import no.mnemonic.commons.utilities.collections.CollectionUtils;
import no.mnemonic.commons.utilities.collections.MapUtils;
import no.mnemonic.commons.utilities.collections.SetUtils;
import no.mnemonic.commons.utilities.lambda.LambdaUtils;
import org.junit.rules.ExternalResource;
import org.mandas.docker.client.DockerClient;
import org.mandas.docker.client.builder.resteasy.ResteasyDockerClientBuilder;
import org.mandas.docker.client.exceptions.DockerException;
import org.mandas.docker.client.messages.ContainerConfig;
import org.mandas.docker.client.messages.ContainerInfo;
import org.mandas.docker.client.messages.HostConfig;
import org.mandas.docker.client.messages.PortBinding;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static no.mnemonic.commons.utilities.ObjectUtils.ifNull;
import static no.mnemonic.commons.utilities.collections.ListUtils.list;
import static no.mnemonic.commons.utilities.collections.MapUtils.Pair.T;
import static no.mnemonic.commons.utilities.collections.MapUtils.map;
/**
* DockerResource is a JUnit resource which starts up an isolated Docker container in a unit test, for example for
* integration tests against an external database. It is advised to use DockerResource as a {@link org.junit.ClassRule}
* because it is expensive to start a Docker container. DockerResource will start up the container only once and will
* make sure that the container is teared down after all tests (i.e. when the JVM shuts down).
*
* In order to start up a container the following steps are performed:
*
* - Initialize a Docker client. If the $DOCKER_HOST environment variable is set it will connect to the Docker
* installation specified by this variable. Otherwise it will try to connect to localhost on TCP port 2375 (default
* Docker daemon port).
* - Initialize and start up a Docker container specified by the name of a Docker image. It is expected that the
* Docker image is already installed (for example by performing 'docker pull').
* - Test that the container is reachable. See {@link #isContainerReachable()} for more information.
* - Prepare the container with additional data. See {@link #prepareContainer()} for more information.
*
*
* After all tests are finished, either successfully, with an exception or by user cancellation, the running container
* is stopped and removed in order to not leave stale containers behind.
*
* Initialize DockerResource in the following way as a {@link org.junit.ClassRule}:
*
* {@code @ClassRule
* public static DockerResource docker = DockerResource.builder()
* .setImageName("busybox")
* .setReachabilityTimeout(30)
* .addApplicationPort(8080)
* .build();}
*
* See {@link DockerResource.Builder} for more information on the configuration properties.
*
* This class provides a basic Docker resource but it is most useful to extend it and override {@link #isContainerReachable()}
* and {@link #prepareContainer()} for more specific use cases, for instance when testing a specific database.
* See {@link CassandraDockerResource} as an example.
*
* Proxy settings
*
* The DockerResource will by default use system properties to determine proxy settings when communicating with the
* docker daemon. To completely disable proxy, you can set the system property "-DDockerResource.disable.proxy=true".
*
* @deprecated Use jupiter-docker instead
*/
@Deprecated
public class DockerResource extends ExternalResource {
private static final String DOCKER_HOST_ENVIRONMENT_VARIABLE = "DOCKER_HOST";
private static final int DEFAULT_DOCKER_DAEMON_PORT = 2375;
private static final int DEFAULT_STOP_TIMEOUT_SECONDS = 10;
private static final int DEFAULT_REACHABILITY_TIMEOUT_SECONDS = 30;
private final String imageName;
private final Set applicationPorts;
private final String exposedPortsRange;
private final int reachabilityTimeout;
private final boolean skipReachabilityCheck;
private final boolean skipPullDockerImage;
private final Supplier dockerClientResolver;
private final Map environmentVariables;
private DockerClient docker;
private String containerID;
/**
* Constructor to override by subclasses.
*
* @param imageName Name of Docker image (required)
* @param applicationPorts Application ports available inside the container (at least one is required)
* @param exposedPortsRange Range of ports for mapping to the outside of the container (optional)
* @param reachabilityTimeout Timeout until testing that container is reachable stops (optional)
* @param skipReachabilityCheck If set skip testing that container is reachable (optional)
* @param skipPullDockerImage If set skip pulling docker image (optional)
* @param dockerClientResolver Function to resolve DockerClient (optional)
* @param environmentVariables Container's environment variables (optional)
* @throws IllegalArgumentException If one of the required parameters is not provided
*/
protected DockerResource(String imageName,
Set applicationPorts,
String exposedPortsRange,
int reachabilityTimeout,
boolean skipReachabilityCheck,
boolean skipPullDockerImage,
Supplier dockerClientResolver,
Map environmentVariables) {
if (StringUtils.isBlank(imageName)) throw new IllegalArgumentException("'imageName' not provided!");
if (CollectionUtils.isEmpty(applicationPorts))
throw new IllegalArgumentException("'applicationPorts' not provided!");
if (!skipReachabilityCheck && reachabilityTimeout <= 0)
throw new IllegalArgumentException("'reachabilityTimeout' not provided!");
this.imageName = imageName;
this.applicationPorts = Collections.unmodifiableSet(applicationPorts.stream()
.filter(Objects::nonNull)
.map(String::valueOf)
.collect(Collectors.toSet()));
this.exposedPortsRange = exposedPortsRange;
this.reachabilityTimeout = reachabilityTimeout;
this.skipReachabilityCheck = skipReachabilityCheck;
this.skipPullDockerImage = skipPullDockerImage;
this.dockerClientResolver = ifNull(dockerClientResolver, (Supplier) this::resolveDockerClient);
this.environmentVariables = Collections.unmodifiableMap(environmentVariables);
// Make sure to always shutdown any containers in order to not leave stale containers on the host machine,
// e.g. in case of exceptions or when the user stops the tests. This won't work if the JVM process is killed.
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdownContainer));
}
/**
* Returns the host where the started Docker container is available. It takes the $DOCKER_HOST environment variable
* into account and falls back to 'localhost' if the variable is not specified.
*
* @return Host where the started Docker container is available
*/
public String getExposedHost() {
return DockerTestUtils.getDockerHost();
}
/**
* DockerResource will map the application ports, which are the ports applications listen to inside the container,
* to random ports on the host machine, which can be used to communicate with the applications. For example,
* the application port 8080 could be mapped to random port 33333 on the host machine.
*
* This method returns the exposed host port for a given application port.
*
* @param applicationPort Application port inside container
* @return Exposed port on host machine
* @throws IllegalStateException If mapped host port cannot be determined
*/
public int getExposedHostPort(int applicationPort) {
int hostPort;
try {
// Fetch container information and find binding for application port which contains the exposed host port.
ContainerInfo info = docker.inspectContainer(containerID);
// Return first available TCP host port bound to application port.
hostPort = map(info.networkSettings().ports())
.getOrDefault(applicationPort + "/tcp", list())
.stream()
.map(PortBinding::hostPort)
.map(Integer::parseInt)
.findFirst()
.orElse(0);
} catch (Exception ex) {
throw new IllegalStateException("Could not determine exposed host port.", ex);
}
if (hostPort <= 0) {
throw new IllegalStateException("Could not determine exposed host port.");
}
return hostPort;
}
/**
* Create builder for DockerResource.
*
* @return Builder object
*/
public static > Builder builder() {
return new Builder<>();
}
/**
* Subclasses can override this method in order to apply additional configuration to the host inside the container.
*
* If not overridden the default configuration will be used.
*
* @param config Default configuration as set up by DockerResource
* @return Modified host configuration
*/
protected HostConfig additionalHostConfig(HostConfig config) {
// By default, return host configuration unchanged.
return config;
}
/**
* Subclasses can override this method in order to apply additional configuration to the container itself.
*
* If not overridden the default configuration will be used.
*
* @param config Default configuration as set up by DockerResource
* @return Modified container configuration
*/
protected ContainerConfig additionalContainerConfig(ContainerConfig config) {
// By default, return container configuration unchanged.
return config;
}
/**
* Subclasses can override this method in order to implement a check to determine if a container is reachable. After
* start up of the container this method will be called until it either returns true or 'reachabilityTimeout' is
* reached. If the container is not reachable until 'reachabilityTimeout' starting up DockerResource will fail with
* a TimeoutException.
*
* If not overridden the method immediately returns true.
*
* @return True if container is reachable
*/
protected boolean isContainerReachable() {
// By default, just return true.
return true;
}
/**
* Subclasses can override this method in order to prepare a container once before tests are executed, for example by
* initializing a database with a schema or inserting some application data into a database. This method is called
* once after it was determined that the container is reachable by {@link #isContainerReachable()}.
*
* If not overridden the method does nothing.
*/
protected void prepareContainer() {
// By default, do nothing.
}
/**
* Expose DockerClient used by DockerResource to subclasses. Use this client when overriding {@link #isContainerReachable()}
* or {@link #prepareContainer()}.
*
* @return DockerClient used by DockerResource
*/
public DockerClient getDockerClient() {
return docker;
}
/**
* Expose containerID of the started container to subclasses. Use this containerID when overriding {@link #isContainerReachable()}
* and {@link #prepareContainer()} in order to communicate with the started container directly.
*
* @return containerID of started container
*/
public String getContainerID() {
return containerID;
}
/**
* Initialize DockerResource before executing tests. It should not be necessary to override this method.
*
* @throws Throwable If initialization fails
*/
@Override
public void before() throws Throwable {
synchronized (DockerResource.class) {
// Only initialize everything once. It will be automatically teared down when the JVM shuts down.
if (docker == null) {
initializeDockerClient();
pullDockerImage();
initializeContainer();
testContainerReachability();
prepareContainer();
}
}
}
/**
* Teardown DockerResource after executing tests. It should not be necessary to override this method.
*/
@Override
public void after() {
synchronized (DockerResource.class) {
if (docker != null) {
shutdownContainer();
docker = null;
}
}
}
private DockerClient resolveDockerClient() {
try {
if (!StringUtils.isBlank(System.getenv(DOCKER_HOST_ENVIRONMENT_VARIABLE))) {
// If DOCKER_HOST is set create docker client from environment variables.
return new ResteasyDockerClientBuilder()
.fromEnv()
.useProxy(useProxySettings())
.build();
} else {
// Otherwise connect to localhost on the default daemon port.
return new ResteasyDockerClientBuilder()
.uri(String.format("http://localhost:%d", DEFAULT_DOCKER_DAEMON_PORT))
.useProxy(useProxySettings())
.build();
}
} catch (Exception ex) {
throw new IllegalStateException("Could not create docker client.", ex);
}
}
private boolean useProxySettings() {
// Allow user to turn off proxy autodetection by setting this property using a system property.
return !Boolean.parseBoolean(ifNull(System.getProperty("DockerResource.disable.proxy"), "false"));
}
private void initializeDockerClient() {
this.docker = dockerClientResolver.get();
try {
// Check that docker daemon is reachable.
if (!"OK".equals(docker.ping())) {
throw new IllegalStateException("ping() did not return OK.");
}
} catch (Exception ex) {
throw new IllegalStateException("Could not connect to docker daemon.", ex);
}
}
private void pullDockerImage() {
if (skipPullDockerImage) {
return;
}
try {
docker.pull(imageName);
} catch (DockerException | InterruptedException e) {
throw new IllegalStateException(String.format("Could not pull docker image '%s'", imageName), e);
}
}
private void initializeContainer() {
PortBinding portBinding = StringUtils.isBlank(exposedPortsRange) ? PortBinding.randomPort("0.0.0.0") :
PortBinding.of("0.0.0.0", exposedPortsRange);
// Bind ports on the host to the application ports of the container randomly or with configured range.
// Also apply any additional host configuration by calling additionalHostConfig().
HostConfig hostConfig = additionalHostConfig(HostConfig.builder()
.portBindings(map(applicationPorts, port -> T(port, list(portBinding))))
.build());
// Convert provided environmental variables to appropriate docker format.
List env = environmentVariables.entrySet()
.stream()
.map(e -> String.format("%s=%s", e.getKey(), e.getValue()))
.collect(Collectors.toList());
// Configure container with the image to start and host port -> application port bindings.
// Also apply any additional container configuration by calling additionalContainerConfig().
ContainerConfig containerConfig = additionalContainerConfig(ContainerConfig.builder()
.image(imageName)
.exposedPorts(applicationPorts)
.hostConfig(hostConfig)
.env(env)
.build());
try {
containerID = docker.createContainer(containerConfig).id();
docker.startContainer(containerID);
} catch (Exception ex) {
throw new IllegalStateException(String.format("Could not start container (image '%s').", imageName), ex);
}
}
private void shutdownContainer() {
// Ignore exceptions because the container goes down anyways.
LambdaUtils.tryTo(() -> {
if (docker == null || StringUtils.isBlank(containerID)) return;
docker.stopContainer(containerID, DEFAULT_STOP_TIMEOUT_SECONDS);
docker.removeContainer(containerID);
});
// But always release client connection.
ObjectUtils.ifNotNullDo(docker, DockerClient::close);
}
private void testContainerReachability() throws Exception {
if (skipReachabilityCheck) return;
if (!LambdaUtils.waitFor(this::isContainerReachable, reachabilityTimeout, TimeUnit.SECONDS)) {
throw new TimeoutException("Could not connect to container before timeout.");
}
}
/**
* Builder to create a DockerResource.
*
* Subclasses of DockerResource can also define own builders extending this builder in order to be able to configure
* the same properties. The configurable properties are exposed as protected fields which can be passed to the
* constructor of a subclass. This constructor in turn should pass them to the constructor}.
* See {@link CassandraDockerResource.Builder} as an example.
*/
public static class Builder> {
protected String imageName;
protected Set applicationPorts;
protected String exposedPortsRange;
protected int reachabilityTimeout = DEFAULT_REACHABILITY_TIMEOUT_SECONDS;
protected boolean skipReachabilityCheck;
protected boolean skipPullDockerImage;
protected Supplier dockerClientResolver;
protected Map environmentVariables = new HashMap<>();
/**
* Build a configured DockerResource.
*
* @return Configured DockerResource
*/
public DockerResource build() {
return new DockerResource(imageName, applicationPorts, exposedPortsRange, reachabilityTimeout,
skipReachabilityCheck, skipPullDockerImage, dockerClientResolver, environmentVariables);
}
/**
* Set image name of container to use. The image must be available in Docker, it is not automatically pulled!
*
* @param imageName Image name
* @return Builder
*/
public T setImageName(String imageName) {
this.imageName = imageName;
return (T) this;
}
/**
* Set application ports which will be used inside the container and exposed outside of the container by mapping to
* ports inside the range specified with {@link #setExposedPortsRange(String)} or random ports.
*
* Also see {@link #getExposedHostPort(int)} for more information.
*
* @param applicationPorts Set of application ports
* @return Builder
*/
public T setApplicationPorts(Set applicationPorts) {
this.applicationPorts = applicationPorts;
return (T) this;
}
/**
* Add a single application port which will be used inside the container and exposed outside of the container by
* mapping to a port inside the range specified with {@link #setExposedPortsRange(String)} or a random port.
*
* Also see {@link #getExposedHostPort(int)} for more information.
*
* @param applicationPort Single application port
* @return Builder
*/
public T addApplicationPort(int applicationPort) {
this.applicationPorts = SetUtils.addToSet(this.applicationPorts, applicationPort);
return (T) this;
}
/**
* Set port range which will be used for exposing ports inside the container to the outside of the container.
*
* @param exposedPortsRange String in format "firstPort-lastPort" which is used for setting a range of ports
* @return Builder
*/
public T setExposedPortsRange(String exposedPortsRange) {
this.exposedPortsRange = exposedPortsRange;
return (T) this;
}
/**
* Set timeout in seconds until test for container reachability stops. Defaults to 30 seconds if not set.
*
* Also see {@link #isContainerReachable()} for more information.
*
* @param reachabilityTimeout Timeout in seconds
* @return Builder
*/
public T setReachabilityTimeout(int reachabilityTimeout) {
this.reachabilityTimeout = reachabilityTimeout;
return (T) this;
}
/**
* Configure DockerResource to skip test for container reachability. Useful if application code implements similar functionality.
*
* @return Builder
*/
public T skipReachabilityCheck() {
this.skipReachabilityCheck = true;
return (T) this;
}
/**
* Skip pulling the image if set to true. Default is to pull the image before running
*
* @param skipPullDockerImage whether to pull the image
* @return Builder
*/
public T setSkipPullDockerImage(boolean skipPullDockerImage) {
this.skipPullDockerImage = skipPullDockerImage;
return (T) this;
}
/**
* Override the default behaviour of how a DockerClient will be created by providing a custom resolver function.
* Should be used with care, but useful for providing a mock during unit testing, for instance.
*
* @param dockerClientResolver Customer DockerClient resolver function
* @return Builder
*/
public T setDockerClientResolver(Supplier dockerClientResolver) {
this.dockerClientResolver = dockerClientResolver;
return (T) this;
}
/**
* Set multiple environment variables for the container.
*
* @param variables Array of key-value pairs
* @return Builder
*/
public T setEnvironmentVariables(MapUtils.Pair... variables) {
this.environmentVariables = MapUtils.map(variables);
return (T) this;
}
/**
* Add an additional environment variable for the container.
*
* @param key Variable name
* @param value Variable value
* @return Builder
*/
public T addEnvironmentVariable(String key, String value) {
this.environmentVariables = MapUtils.addToMap(this.environmentVariables, key, value);
return (T) this;
}
}
}