All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.arakelian.docker.junit.Container Maven / Gradle / Ivy

There is a newer version: 4.1.0
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.arakelian.docker.junit;

import static com.spotify.docker.client.DockerClient.LogsParam.follow;
import static com.spotify.docker.client.DockerClient.LogsParam.stderr;
import static com.spotify.docker.client.DockerClient.LogsParam.stdout;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import org.immutables.value.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.arakelian.docker.junit.model.ContainerConfigurer;
import com.arakelian.docker.junit.model.DockerConfig;
import com.arakelian.docker.junit.model.HostConfigurer;
import com.arakelian.docker.junit.model.StartedListener;
import com.google.common.base.Preconditions;
import com.google.common.collect.Maps;
import com.spotify.docker.client.DefaultDockerClient;
import com.spotify.docker.client.DockerClient;
import com.spotify.docker.client.DockerClient.RemoveContainerParam;
import com.spotify.docker.client.LogStream;
import com.spotify.docker.client.exceptions.DockerCertificateException;
import com.spotify.docker.client.exceptions.DockerException;
import com.spotify.docker.client.exceptions.ImageNotFoundException;
import com.spotify.docker.client.messages.ContainerConfig;
import com.spotify.docker.client.messages.ContainerCreation;
import com.spotify.docker.client.messages.ContainerInfo;
import com.spotify.docker.client.messages.ContainerState;
import com.spotify.docker.client.messages.HostConfig;
import com.spotify.docker.client.messages.PortBinding;

/**
 * Handles life-cycle management of a Docker container, e.g. starting and stopping.
 *
 * @author Greg Arakelian
 */
public class Container {
    @Value.Immutable
    public interface Binding {
        public String getHost();

        public int getPort();
    }

    private static final Logger LOGGER = LoggerFactory.getLogger(DockerRule.class);

    /**
     * Returns true if a connection can be established to the given socket address within the
     * timeout provided.
     *
     * @param socketAddress
     *            socket address
     * @param timeoutMsecs
     *            timeout
     * @return true if a connection can be established to the given socket address
     */
    public static boolean isSocketAlive(final SocketAddress socketAddress, final int timeoutMsecs) {
        final Socket socket = new Socket();
        try {
            socket.connect(socketAddress, timeoutMsecs);
            socket.close();
            return true;
        } catch (final SocketTimeoutException exception) {
            return false;
        } catch (final IOException exception) {
            return false;
        }
    }

    private final ContainerConfig containerConfig;

    /**
     * The docker container name. Same value that you would see if you ran "docker ps -a" in the
     * NAMES column.
     */
    private final String name;

    /** Docker container configuration **/
    private final DockerConfig config;

    /** Access to docker client API **/
    private DockerClient client;

    /** Docker container information **/
    private ContainerInfo info;

    /** Container id **/
    private String containerId;

    /** Reference to the JVM shutdown hook, if registered */
    private Thread shutdownHook;

    /**
     * Arbitrary data that can be associated with this container.
     */
    private Map context = Maps.newLinkedHashMap();

    /** Synchronization monitor for the "refresh" and "destroy" */
    private final Object startStopMonitor = new Object();

    /** Flag that indicates whether the container has been started */
    private final AtomicBoolean started = new AtomicBoolean();

    /** Flag that indicates whether this container has been stopped already */
    private final AtomicBoolean stopped = new AtomicBoolean();

    /**
     * When reference count is non-zero, we cannot close this container because it is required as
     * part of a unit test.
     **/
    private final AtomicInteger refCount = new AtomicInteger();

    /**
     * Construct a container.
     *
     * @param config
     *            docker container configuration
     */
    public Container(final DockerConfig config) {
        this.name = config.getName();
        this.config = config;
        this.containerConfig = createContainerConfig(config).build();
    }

    public final int addRef() {
        return refCount.incrementAndGet();
    }

    public final DockerClient getClient() {
        return client;
    }

    public DockerConfig getConfig() {
        return config;
    }

    public  T getData(String name, Class clazz) {
        final Object value = context.get(name);
        if (clazz.isInstance(value)) {
            return clazz.cast(value);
        }
        throw new IllegalStateException(name + " must be non-null");
    }

    public void setData(String name, Object value) {
        context.put(name, value);
    }

    public final String getContainerId() {
        assertStarted();
        return containerId;
    }

    public final ContainerInfo getInfo() {
        assertStarted();
        return info;
    }

    /**
     * Returns the host and port information for the given Docker port name.
     *
     * @param portName
     *            Docker port name, e.g. '9200/tcp'
     * @return the host and port information
     * @throws IllegalStateException
     *             if the container has not been started
     * @throws IllegalArgumentException
     *             the given port name does not exist
     */
    public final Binding getPortBinding(final String portName)
            throws IllegalStateException, IllegalArgumentException {
        assertStarted();

        final Map> ports = info.networkSettings().ports();
        final List portBindings = ports.get(portName);
        if (portBindings == null || portBindings.isEmpty()) {
            throw new IllegalArgumentException("Unknown port binding: " + portName);
        }

        final PortBinding portBinding = portBindings.get(0);
        return ImmutableBinding.builder() //
                .host(portBinding.hostIp()) //
                .port(Integer.parseInt(portBinding.hostPort())) //
                .build();
    }

    public final int getRefCount() {
        return refCount.get();
    }

    /**
     * Returns true if container has been started.
     *
     * @return true if container has been started.
     */
    public boolean isStarted() {
        return started.get();
    }

    public final int releaseRef() {
        return refCount.decrementAndGet();
    }

    public void start() throws Exception {
        synchronized (this.startStopMonitor) {
            if (this.started.get()) {
                Preconditions.checkState(client != null, "client must be non-null");
                final ContainerInfo inspect = client.inspectContainer(name);
                final ContainerState state = inspect.state();
                if (!state.running()) {
                    throw new IllegalStateException(
                            "Container " + name + " is not running (check exit code!): " + state);
                }

                // already running
                return;
            }

            LOGGER.info("Starting container {} with {}", containerConfig.image(), containerConfig);
            this.stopped.set(false);
            this.started.set(true);

            registerShutdownHook();
            try {
                client = createDockerClient();
            } catch (final Exception e) {
                stop();
                throw e;
            }

            boolean created = false;
            boolean running = false;
            if (name != null) {
                try {
                    final ContainerInfo existing = client.inspectContainer(name);
                    final ContainerState state = existing.state();

                    final String image = containerConfig.image();
                    if (!image.equals(existing.config().image())) {
                        // existing container has different image
                        if (state != null && state.running() && state.running().booleanValue()) {
                            stopContainerQuietly(image, existing.id());
                        }
                        removeContainerQuietly(existing.id());
                    } else {
                        created = true;
                        running = state != null && state.running() && state.running().booleanValue();
                        containerId = existing.id();
                        info = existing;
                    }
                } catch (final DockerException e) {
                    // we could not get information about container, fall through
                }
            }

            if (!created) {
                pullImage();
                containerId = createContainer();
            }
            if (!running) {
                startContainer();
                info = client.inspectContainer(containerId);
            }

            // notify listener than container has been started
            for (final StartedListener listener : config.getStartedListener()) {
                listener.onStarted(this);
            }
        }
    }

    /**
     * Instructs Docker to stop the container
     */
    public void stop() {
        synchronized (this.startStopMonitor) {
            doStop();

            // If we registered a JVM shutdown hook, we don't need it anymore now:
            // We've already explicitly closed the context.
            if (this.shutdownHook != null) {
                try {
                    Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
                } catch (final IllegalStateException ex) {
                    // ignore - VM is already shutting down
                }
            }
        }
    }

    /**
     * Wait for nth occurrence of message to appear in docker logs within the specified timeframe.
     *
     * @param timeout
     *            timeout value
     * @param unit
     *            timeout units
     * @param messages
     *            The sequence of messages to wait for
     * @throws DockerException
     *             if docker throws exception while tailing logs
     * @throws InterruptedException
     *             if thread interrupted while waiting for timeout
     */
    public final void waitForLog(final int timeout, final TimeUnit unit, final String... messages)
            throws DockerException, InterruptedException {
        final long startTime = System.currentTimeMillis();
        final long timeoutTimeMillis = startTime + TimeUnit.MILLISECONDS.convert(timeout, unit);

        int n = 0;
        LOGGER.info("Tailing logs for \"{}\"", messages[n]);
        final LogStream logs = client.logs(containerId, follow(), stdout(), stderr());
        while (logs.hasNext()) {
            if (Thread.interrupted()) {
                // manual check for thread being terminated
                throw new InterruptedException();
            }
            final String message = messages[n];
            final String log = StandardCharsets.UTF_8.decode(logs.next().content()).toString();
            if (log.contains(message)) {
                LOGGER.info(
                        "Successfully received \"{}\" after {}ms",
                        message,
                        System.currentTimeMillis() - startTime);
                if (++n >= messages.length) {
                    return;
                }
            }
            if (startTime > timeoutTimeMillis) {
                throw new IllegalStateException("Timeout waiting for log \"" + message + "\"");
            }
        }
    }

    /**
     * Wait for a sequence of messages to appear in docker logs.
     *
     * @param messages
     *            The sequence of messages to wait for
     * @throws DockerException
     *             if docker throws exception while tailing logs
     * @throws InterruptedException
     *             if thread interrupted while waiting for timeout
     */
    public final void waitForLog(final String... messages) throws DockerException, InterruptedException {
        waitForLog(30, TimeUnit.SECONDS, messages);
    }

    /**
     * Wait for the specified port to accept socket connection.
     *
     * @param portName
     *            Docker port name, e.g. '9200/tcp'
     */
    public final void waitForPort(final String portName) {
        final Binding binding = getPortBinding(portName);
        waitForPort(binding.getHost(), binding.getPort());
    }

    /**
     * Wait for the specified port to accept socket connection.
     *
     * @param host
     *            host name
     * @param port
     *            port number
     */
    public final void waitForPort(final String host, final int port) {
        waitForPort(host, port, 30, TimeUnit.SECONDS);
    }

    /**
     * Wait for the specified port to accept socket connection within a given time frame.
     *
     * @param host
     *            target host name
     * @param port
     *            target port number
     * @param timeout
     *            timeout value
     * @param unit
     *            timeout units
     * @throws IllegalArgumentException
     *             if invalid arguments are passed to method
     */
    public final void waitForPort(final String host, final int port, final int timeout, final TimeUnit unit)
            throws IllegalArgumentException {
        Preconditions.checkArgument(host != null, "host must be non-null");
        Preconditions.checkArgument(port > 0, "port must be positive integer");
        Preconditions.checkArgument(unit != null, "unit must be non-null");

        final long startTime = System.currentTimeMillis();
        final long timeoutTime = startTime + TimeUnit.MILLISECONDS.convert(timeout, unit);

        LOGGER.info("Waiting for docker container at {}:{} for {} {}", host, port, timeout, unit);
        final SocketAddress address = new InetSocketAddress(host, port);
        for (;;) {
            if (isSocketAlive(address, 2000)) {
                LOGGER.info(
                        "Successfully connected to container at {}:{} after {}ms",
                        host,
                        port,
                        System.currentTimeMillis() - startTime);
                return;
            }
            try {
                Thread.sleep(1000);
                LOGGER.info("Waiting for container at {}:{}", host, port);
                if (System.currentTimeMillis() > timeoutTime) {
                    LOGGER.error("Failed to connect with container at {}:{}", host, port);
                    throw new IllegalStateException(
                            "Timeout waiting for socket connection to " + host + ":" + port);
                }
            } catch (final InterruptedException ie) {
                throw new IllegalStateException(ie);
            }
        }
    }

    /**
     * Wait for the specified port to accept socket connection within a given time frame.
     *
     * @param portName
     *            docker port name, e.g. 9200/tcp
     * @param timeout
     *            timeout value
     * @param unit
     *            timeout units
     */
    public final void waitForPort(final String portName, final int timeout, final TimeUnit unit) {
        final Binding binding = getPortBinding(portName);
        waitForPort(binding.getHost(), binding.getPort(), timeout, unit);
    }

    private void assertStarted() {
        synchronized (this.startStopMonitor) {
            if (!started.get()) {
                throw new IllegalStateException("Docker container not started: " + containerConfig);
            }
        }
    }

    private String createContainer() throws InterruptedException {
        final long startTime = System.currentTimeMillis();

        // equivalent to "docker run"
        final ContainerCreation container;
        try {
            if (name == null) {
                container = client.createContainer(containerConfig);
            } else {
                container = client.createContainer(containerConfig, name);
            }
            return container.id();
        } catch (final DockerException e) {
            throw new IllegalStateException("Unable to create container using " + containerConfig, e);
        } finally {
            final long elapsedMillis = System.currentTimeMillis() - startTime;
            LOGGER.info("Created container {} in {}ms", containerConfig.image(), elapsedMillis);
        }
    }

    private void doStop() {
        if (this.started.get() && this.stopped.compareAndSet(false, true)) {
            LOGGER.info("Stopping {} container", config.getName());
            this.started.set(false);

            if (containerId != null && containerId.length() != 0) {
                stopContainerQuietly(containerConfig.image(), containerId);
                if (name == null || config.isAlwaysRemoveContainer()) {
                    removeContainerQuietly(containerId);
                }
            }

            if (client != null) {
                client.close();
            }
        }
    }

    private void pullImage() throws DockerException, InterruptedException {
        // equivalent to "docker pull"
        final long startTime = System.currentTimeMillis();
        final String image = containerConfig.image();
        try {
            boolean found = false;
            try {
                client.inspectImage(image);
                LOGGER.info("Docker image already exists: {}", image);
                found = true;
            } catch (final ImageNotFoundException e) {
                found = false;
            }

            if (config.isAlwaysPullLatestImage() || !found) {
                LOGGER.info("Pulling docker image: {}", image);
                client.pull(image);
            }
        } catch (final DockerException e) {
            throw new DockerException("Unable to pull docker image " + image, e);
        } finally {
            final long elapsedMillis = System.currentTimeMillis() - startTime;
            LOGGER.info("Docker image {} pulled in {}ms", image, elapsedMillis);
        }
    }

    /**
     * Register a shutdown hook with the JVM runtime, closing this context on JVM shutdown unless it
     * has already been closed at that time.
     * 

* Delegates to {@code doClose()} for the actual closing procedure. * * @see Runtime#addShutdownHook * @see #close() * @see #doClose() */ private void registerShutdownHook() { if (this.shutdownHook == null) { // No shutdown hook registered yet. this.shutdownHook = new Thread() { @Override public void run() { synchronized (startStopMonitor) { LOGGER.info("JVM shutting down"); Container.this.doStop(); } } }; Runtime.getRuntime().addShutdownHook(this.shutdownHook); } } private void removeContainerQuietly(final String idOrName) { final long startTime = System.currentTimeMillis(); try { LOGGER.info("Removing docker container {} with id {}", containerConfig.image(), idOrName); client.removeContainer(idOrName, RemoveContainerParam.removeVolumes(true)); } catch (DockerException | InterruptedException e) { // log error and ignore exception LOGGER.warn( "Unable to remove docker container {} with id {}", containerConfig.image(), idOrName, e); } finally { final long elapsedMillis = System.currentTimeMillis() - startTime; LOGGER.info( "Container {} with id {} removed in {}ms", containerConfig.image(), idOrName, elapsedMillis); } } /** * Instructs Docker to start the container * * @throws DockerException * if docker cannot start the container */ private void startContainer() throws DockerException { final long startTime = System.currentTimeMillis(); try { LOGGER.info("Starting container {} with id {}", containerConfig.image(), containerId); client.startContainer(containerId); } catch (DockerException | InterruptedException e) { throw new DockerException( "Unable to start container " + containerConfig.image() + " with id " + containerId, e); } finally { final long elapsedMillis = System.currentTimeMillis() - startTime; LOGGER.info("Container {} started in {}ms", containerConfig.image(), elapsedMillis); } } /** * Utility method to stop a container with the given image name and id/name. * * @param image * image name * @param idOrName * container id or name */ private void stopContainerQuietly(final String image, final String idOrName) { final long startTime = System.currentTimeMillis(); try { LOGGER.info("Killing docker container {} with id {}", image, idOrName); final int secondsToWaitBeforeKilling = 10; client.stopContainer(containerId, secondsToWaitBeforeKilling); } catch (DockerException | InterruptedException e) { // log error and ignore exception LOGGER.warn("Unable to kill docker container {} with id", image, idOrName, e); } finally { final long elapsedMillis = System.currentTimeMillis() - startTime; LOGGER.info("Docker container {} with id {} killed in {}ms", image, idOrName, elapsedMillis); } } /** * Returns a new {@link ContainerConfig.Builder} based upon the given configuration. * * Descendant classes can override this method to customize the configuration of the Docker * container beyond what is allowed by {@link DockerConfig}. * * @param config * docker container configuration * @return a new {@link ContainerConfig.Builder} */ protected ContainerConfig.Builder createContainerConfig(final DockerConfig config) { final ContainerConfig.Builder builder = ContainerConfig.builder() // .hostConfig(createHostConfig(config).build()) // .image(config.getImage()) // .networkDisabled(false) // .exposedPorts(config.getPorts()); for (final ContainerConfigurer configurer : config.getContainerConfigurer()) { configurer.configureContainer(builder); } return builder; } protected DockerClient createDockerClient() throws DockerClientException { try { return DefaultDockerClient.fromEnv() // .connectTimeoutMillis(5000) // .readTimeoutMillis(20000) // .build(); } catch (final IllegalStateException | IllegalArgumentException | DockerCertificateException e) { throw new DockerClientException("Unable to create docker client", e); } } /** * Returns a new {@link HostConfig.Builder} based upon the given configuration. * * Descendant classes can override this method to customize the configuration of the Docker * container's host beyond what is allowed by {@link DockerConfig}. * * @param config * docker container configuration * @return a new {@link HostConfig.Builder} */ protected HostConfig.Builder createHostConfig(final DockerConfig config) { final HostConfig.Builder builder = HostConfig.builder() // .portBindings(createPortBindings(config.getPorts())); for (final HostConfigurer configurer : config.getHostConfigurer()) { configurer.configureHost(builder); } return builder; } protected Map> createPortBindings(final String[] exposedPorts) { final Map> portBindings = new HashMap<>(); if (exposedPorts != null) { for (final String port : exposedPorts) { final List hostPorts = Collections .singletonList(PortBinding.randomPort("0.0.0.0")); portBindings.put(port, hostPorts); } } return portBindings; } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy