com.spotify.helios.system.SystemTestBase.java Source code

Java tutorial

Introduction

Here is the source code for com.spotify.helios.system.SystemTestBase.java

Source

/*
 * Copyright (c) 2014 Spotify AB.
 *
 * Licensed 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.spotify.helios.system;

import com.google.common.base.Charsets;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Range;
import com.google.common.io.Files;
import com.google.common.util.concurrent.FutureFallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.Service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.spotify.docker.client.ContainerNotFoundException;
import com.spotify.docker.client.DefaultDockerClient;
import com.spotify.docker.client.DockerCertificates;
import com.spotify.docker.client.DockerClient;
import com.spotify.docker.client.DockerException;
import com.spotify.docker.client.DockerRequestException;
import com.spotify.docker.client.ImageNotFoundException;
import com.spotify.docker.client.LogMessage;
import com.spotify.docker.client.LogReader;
import com.spotify.docker.client.messages.Container;
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.HostConfig;
import com.spotify.docker.client.messages.PortBinding;
import com.spotify.helios.Polling;
import com.spotify.helios.TemporaryPorts;
import com.spotify.helios.TemporaryPorts.AllocatedPort;
import com.spotify.helios.ZooKeeperTestManager;
import com.spotify.helios.ZooKeeperTestingServerManager;
import com.spotify.helios.agent.AgentMain;
import com.spotify.helios.cli.CliMain;
import com.spotify.helios.cli.command.JobCreateCommand;
import com.spotify.helios.client.HeliosClient;
import com.spotify.helios.common.Json;
import com.spotify.helios.common.descriptors.Deployment;
import com.spotify.helios.common.descriptors.DeploymentGroupStatus;
import com.spotify.helios.common.descriptors.HostStatus;
import com.spotify.helios.common.descriptors.Job;
import com.spotify.helios.common.descriptors.Job.Builder;
import com.spotify.helios.common.descriptors.JobId;
import com.spotify.helios.common.descriptors.JobStatus;
import com.spotify.helios.common.descriptors.PortMapping;
import com.spotify.helios.common.descriptors.ServiceEndpoint;
import com.spotify.helios.common.descriptors.ServicePorts;
import com.spotify.helios.common.descriptors.TaskStatus;
import com.spotify.helios.common.descriptors.ThrottleState;
import com.spotify.helios.common.protocol.DeploymentGroupStatusResponse;
import com.spotify.helios.common.protocol.JobDeleteResponse;
import com.spotify.helios.common.protocol.JobUndeployResponse;
import com.spotify.helios.master.MasterMain;
import com.spotify.helios.servicescommon.DockerHost;
import com.spotify.helios.servicescommon.coordination.CuratorClientFactory;
import com.spotify.helios.servicescommon.coordination.Paths;
import com.sun.jersey.api.client.ClientResponse;

import org.apache.curator.framework.CuratorFramework;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.jetbrains.annotations.NotNull;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.junit.rules.TestRule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.net.Socket;
import java.net.URI;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import static com.google.common.base.CharMatcher.WHITESPACE;
import static com.google.common.base.Charsets.UTF_8;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.isNullOrEmpty;
import static com.google.common.collect.Iterables.concat;
import static com.google.common.collect.Lists.newArrayList;
import static com.spotify.helios.common.descriptors.Job.EMPTY_ENV;
import static com.spotify.helios.common.descriptors.Job.EMPTY_EXPIRES;
import static com.spotify.helios.common.descriptors.Job.EMPTY_GRACE_PERIOD;
import static com.spotify.helios.common.descriptors.Job.EMPTY_HOSTNAME;
import static com.spotify.helios.common.descriptors.Job.EMPTY_PORTS;
import static com.spotify.helios.common.descriptors.Job.EMPTY_REGISTRATION;
import static com.spotify.helios.common.descriptors.Job.EMPTY_VOLUMES;
import static java.lang.Integer.toHexString;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

public abstract class SystemTestBase {

    private static final Logger log = LoggerFactory.getLogger(SystemTestBase.class);

    public static final int WAIT_TIMEOUT_SECONDS = 40;
    public static final int LONG_WAIT_SECONDS = 400;

    public static final String BUSYBOX = "busybox:latest";
    public static final String BUSYBOX_WITH_DIGEST = "busybox@sha256:16a2a52884c2a9481ed267c2d46483eac7693b813a63132368ab098a71303f8a";
    public static final String NGINX = "rohan/nginx-alpine:latest";
    public static final String UHTTPD = "fnichol/docker-uhttpd:latest";
    public static final String ALPINE = "onescience/alpine:latest";
    public static final String MEMCACHED = "rohan/memcached-mini:latest";
    public static final List<String> IDLE_COMMAND = asList("sh", "-c",
            "trap 'exit 0' SIGINT SIGTERM; while :; do sleep 1; done");

    public final String testTag = "test_" + randomHexString();
    public final String testJobName = "job_" + testTag;
    public final String testJobVersion = "v" + randomHexString();
    public final String testJobNameAndVersion = testJobName + ":" + testJobVersion;

    public static final DockerHost DOCKER_HOST = DockerHost.fromEnv();

    public static final String TEST_USER = "test-user";
    public static final String TEST_HOST = "test-host";
    public static final String TEST_MASTER = "test-master";

    @Rule
    public final TemporaryPorts temporaryPorts = TemporaryPorts.create();

    @Rule
    public final TemporaryFolder temporaryFolder = new TemporaryFolder();
    @Rule
    public final ExpectedException exception = ExpectedException.none();
    @Rule
    public final TestRule watcher = new LoggingTestWatcher();

    private int masterPort;
    private int masterAdminPort;
    private String masterEndpoint;
    private String masterAdminEndpoint;
    private boolean integrationMode;
    private Range<Integer> dockerPortRange;

    private final List<Service> services = newArrayList();
    private final List<HeliosClient> clients = Lists.newArrayList();

    private String testHost;
    private Path agentStateDirs;
    private Path masterStateDirs;
    private String masterName;

    private ZooKeeperTestManager zk;
    protected static String zooKeeperNamespace = null;
    protected final String zkClusterId = String.valueOf(ThreadLocalRandom.current().nextInt(10000));

    /** An HttpClient that can be used for sending arbitrary HTTP requests */
    protected CloseableHttpClient httpClient;

    @BeforeClass
    public static void staticSetup() {
        SLF4JBridgeHandler.removeHandlersForRootLogger();
        SLF4JBridgeHandler.install();
    }

    @Before
    public void baseSetup() throws Exception {
        System.setProperty("user.name", TEST_USER);
        masterPort = temporaryPorts.localPort("helios master");
        masterAdminPort = temporaryPorts.localPort("helios master admin");

        String className = getClass().getName();
        if (className.endsWith("ITCase")) {
            masterEndpoint = checkNotNull(System.getenv("HELIOS_ENDPOINT"),
                    "For integration tests, HELIOS_ENDPOINT *must* be set");
            integrationMode = true;
        } else if (className.endsWith("Test")) {
            integrationMode = false;
            masterEndpoint = "http://localhost:" + masterPort();
            masterAdminEndpoint = "http://localhost:" + masterAdminPort();
            // unit test
        } else {
            throw new RuntimeException("Test class' name must end in either 'Test' or 'ITCase'.");
        }

        zk = zooKeeperTestManager();
        listThreads();
        zk.ensure("/config");
        zk.ensure("/status");
        agentStateDirs = temporaryFolder.newFolder("helios-agents").toPath();
        masterStateDirs = temporaryFolder.newFolder("helios-masters").toPath();

        // TODO (mbrown): not 100% sure what a minimal client is but it sounds good
        httpClient = HttpClients.createMinimal();
    }

    @Before
    public void dockerSetup() throws Exception {
        final String portRange = System.getenv("DOCKER_PORT_RANGE");

        final AllocatedPort allocatedPort;
        final int probePort;
        if (portRange != null) {
            final String[] parts = portRange.split(":", 2);
            dockerPortRange = Range.closedOpen(Integer.valueOf(parts[0]), Integer.valueOf(parts[1]));
            allocatedPort = Polling.await(LONG_WAIT_SECONDS, SECONDS, new Callable<AllocatedPort>() {
                @Override
                public AllocatedPort call() throws Exception {
                    final int port = ThreadLocalRandom.current().nextInt(dockerPortRange.lowerEndpoint(),
                            dockerPortRange.upperEndpoint());
                    return temporaryPorts.tryAcquire("docker-probe", port);
                }
            });
            probePort = allocatedPort.port();
        } else {
            dockerPortRange = temporaryPorts.localPortRange("docker", 10);
            probePort = dockerPortRange().lowerEndpoint();
            allocatedPort = null;
        }

        try {
            assertDockerReachable(probePort);
        } finally {
            if (allocatedPort != null) {
                allocatedPort.release();
            }
        }
    }

    protected DockerClient getNewDockerClient() throws Exception {
        if (isNullOrEmpty(DOCKER_HOST.dockerCertPath())) {
            return new DefaultDockerClient(DOCKER_HOST.uri());
        } else {
            final Path dockerCertPath = java.nio.file.Paths.get(DOCKER_HOST.dockerCertPath());
            return new DefaultDockerClient(DOCKER_HOST.uri(), new DockerCertificates(dockerCertPath));
        }
    }

    private void assertDockerReachable(final int probePort) throws Exception {
        try (final DockerClient docker = getNewDockerClient()) {
            // Pull our base images
            try {
                docker.inspectImage(BUSYBOX);
            } catch (ImageNotFoundException e) {
                docker.pull(BUSYBOX);
            }

            try {
                docker.inspectImage(ALPINE);
            } catch (ImageNotFoundException e) {
                docker.pull(ALPINE);
            }

            // Start a container with an exposed port
            final HostConfig hostConfig = HostConfig.builder()
                    .portBindings(ImmutableMap.of("4711/tcp", singletonList(PortBinding.of("0.0.0.0", probePort))))
                    .build();
            final ContainerConfig config = ContainerConfig.builder().image(BUSYBOX)
                    .cmd("nc", "-p", "4711", "-lle", "cat").exposedPorts(ImmutableSet.of("4711/tcp"))
                    .hostConfig(hostConfig).build();
            final ContainerCreation creation = docker.createContainer(config, testTag + "-probe");
            final String containerId = creation.id();
            docker.startContainer(containerId);

            // Wait for container to come up
            Polling.await(5, SECONDS, new Callable<Object>() {
                @Override
                public Object call() throws Exception {
                    final ContainerInfo info = docker.inspectContainer(containerId);
                    return info.state().running() ? true : null;
                }
            });

            log.info("Verifying that docker containers are reachable");
            try {
                Polling.awaitUnchecked(5, SECONDS, new Callable<Object>() {
                    @Override
                    public Object call() throws Exception {
                        log.info("Probing: {}:{}", DOCKER_HOST.address(), probePort);
                        try (final Socket ignored = new Socket(DOCKER_HOST.address(), probePort)) {
                            return true;
                        } catch (IOException e) {
                            return false;
                        }
                    }
                });
            } catch (TimeoutException e) {
                fail("Please ensure that DOCKER_HOST is set to an address that where containers can "
                        + "be reached. If docker is running in a local VM, DOCKER_HOST must be set to the "
                        + "address of that VM. If docker can only be reached on a limited port range, "
                        + "set the environment variable DOCKER_PORT_RANGE=start:end");
            }

            docker.killContainer(containerId);
        }
    }

    protected ZooKeeperTestManager zooKeeperTestManager() {
        return new ZooKeeperTestingServerManager(zooKeeperNamespace);
    }

    @After
    public void baseTeardown() throws Exception {
        tearDownJobs();
        for (final HeliosClient client : clients) {
            client.close();
        }
        clients.clear();

        for (Service service : services) {
            try {
                service.stopAsync();
            } catch (Exception e) {
                log.error("Uncaught exception", e);
            }
        }
        for (Service service : services) {
            try {
                service.awaitTerminated();
            } catch (Exception e) {
                log.error("Service failed", e);
            }
        }
        services.clear();

        // Clean up docker
        try (final DockerClient dockerClient = getNewDockerClient()) {
            final List<Container> containers = dockerClient.listContainers();
            for (final Container container : containers) {
                for (final String name : container.names()) {
                    if (name.contains(testTag)) {
                        try {
                            dockerClient.killContainer(container.id());
                        } catch (DockerException e) {
                            e.printStackTrace();
                        }
                        break;
                    }
                }
            }
        } catch (Exception e) {
            log.error("Docker client exception", e);
        }

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

        listThreads();
    }

    private void listThreads() {
        final Set<Thread> threads = Thread.getAllStackTraces().keySet();
        final Map<String, Thread> sorted = Maps.newTreeMap();
        for (final Thread t : threads) {
            final ThreadGroup tg = t.getThreadGroup();
            if (t.isAlive() && (tg == null || !tg.getName().equals("system"))) {
                sorted.put(t.getName(), t);
            }
        }
        log.info("= THREADS " + Strings.repeat("=", 70));
        for (final Thread t : sorted.values()) {
            final ThreadGroup tg = t.getThreadGroup();
            log.info("{}: \"{}\" ({}{})", t.getId(), t.getName(), (tg == null ? "" : tg.getName() + " "),
                    (t.isDaemon() ? "daemon" : ""));
        }
        log.info(Strings.repeat("=", 80));
    }

    protected void tearDownJobs() throws InterruptedException, ExecutionException {
        if (!isIntegration()) {
            return;
        }

        if (System.getenv("ITCASE_PRESERVE_JOBS") != null) {
            return;
        }

        final List<ListenableFuture<JobUndeployResponse>> undeploys = Lists.newArrayList();
        final HeliosClient c = defaultClient();
        final Map<JobId, Job> jobs = c.jobs().get();
        for (JobId jobId : jobs.keySet()) {
            if (!jobId.toString().startsWith(testTag)) {
                continue;
            }
            final JobStatus st = c.jobStatus(jobId).get();
            final Set<String> hosts = st.getDeployments().keySet();
            for (String host : hosts) {
                log.info("Undeploying job " + jobId);
                undeploys.add(c.undeploy(jobId, host));
            }
        }
        Futures.allAsList(undeploys);

        final List<ListenableFuture<JobDeleteResponse>> deletes = Lists.newArrayList();
        for (JobId jobId : jobs.keySet()) {
            if (!jobId.toString().startsWith(testTag)) {
                continue;
            }
            log.info("Deleting job " + jobId);
            deletes.add(c.deleteJob(jobId));
        }
        Futures.allAsList(deletes);
    }

    protected boolean isIntegration() {
        return integrationMode;
    }

    protected TemporaryPorts temporaryPorts() {
        return temporaryPorts;
    }

    protected ZooKeeperTestManager zk() {
        return zk;
    }

    protected String masterEndpoint() {
        return masterEndpoint;
    }

    protected String masterAdminEndpoint() {
        return masterAdminEndpoint;
    }

    protected String masterName() throws InterruptedException, ExecutionException {
        if (integrationMode) {
            if (masterName == null) {
                masterName = defaultClient().listMasters().get().get(0);
            }
            return masterName;
        } else {
            return "test-master";
        }
    }

    protected HeliosClient defaultClient() {
        return client(TEST_USER, masterEndpoint());
    }

    protected HeliosClient client(final String user, final String endpoint) {
        final HeliosClient client = HeliosClient.newBuilder().setUser(user)
                .setEndpoints(singletonList(URI.create(endpoint))).build();
        clients.add(client);
        return client;
    }

    protected int masterPort() {
        return masterPort;
    }

    protected int masterAdminPort() {
        return masterAdminPort;
    }

    public Range<Integer> dockerPortRange() {
        return dockerPortRange;
    }

    protected String testHost() throws InterruptedException, ExecutionException {
        if (integrationMode) {
            if (testHost == null) {
                final List<String> hosts = defaultClient().listHosts().get();
                testHost = hosts.get(new SecureRandom().nextInt(hosts.size()));
            }
            return testHost;
        } else {
            return TEST_HOST;
        }
    }

    protected List<String> setupDefaultMaster(String... args) throws Exception {
        return setupDefaultMaster(0, args);
    }

    protected List<String> setupDefaultMaster(final int offset, String... args) throws Exception {
        if (isIntegration()) {
            checkArgument(args.length == 0,
                    "cannot start default master in integration test with arguments passed");
            return null;
        }

        // TODO (dano): Move this bootstrapping to something reusable
        final CuratorFramework curator = zk.curator();
        curator.newNamespaceAwareEnsurePath(Paths.configHosts()).ensure(curator.getZookeeperClient());
        curator.newNamespaceAwareEnsurePath(Paths.configJobs()).ensure(curator.getZookeeperClient());
        curator.newNamespaceAwareEnsurePath(Paths.configJobRefs()).ensure(curator.getZookeeperClient());
        curator.newNamespaceAwareEnsurePath(Paths.statusHosts()).ensure(curator.getZookeeperClient());
        curator.newNamespaceAwareEnsurePath(Paths.statusMasters()).ensure(curator.getZookeeperClient());
        curator.newNamespaceAwareEnsurePath(Paths.historyJobs()).ensure(curator.getZookeeperClient());
        curator.newNamespaceAwareEnsurePath(Paths.configId(zkClusterId)).ensure(curator.getZookeeperClient());

        final List<String> argsList = Lists.newArrayList("-vvvv", "--no-log-setup", "--http",
                "http://0.0.0.0:" + (masterPort() + offset), "--admin=" + (masterAdminPort() + offset), "--domain",
                "", "--zk", zk.connectString());

        final String name;
        if (asList(args).contains("--name")) {
            name = args[asList(args).indexOf("--name") + 1];
        } else {
            name = TEST_MASTER + offset;
            argsList.addAll(asList("--name", TEST_MASTER));
        }

        final String stateDir = masterStateDirs.resolve(name).toString();
        argsList.addAll(asList("--state-dir", stateDir));

        argsList.addAll(asList(args));

        return argsList;
    }

    protected MasterMain startDefaultMaster(String... args) throws Exception {
        return startDefaultMaster(0, args);
    }

    protected MasterMain startDefaultMaster(Map<String, String> environmentVariables, String... args)
            throws Exception {
        return startDefaultMaster(0, environmentVariables, args);
    }

    protected MasterMain startDefaultMaster(final int offset, String... args) throws Exception {
        return startDefaultMaster(offset, ImmutableMap.<String, String>of(), args);
    }

    protected MasterMain startDefaultMaster(final int offset, final Map<String, String> environmentVariables,
            final String... args) throws Exception {
        final List<String> argsList = setupDefaultMaster(offset, args);

        if (argsList == null) {
            return null;
        }

        final MasterMain master = startMaster(environmentVariables, argsList.toArray(new String[argsList.size()]));

        waitForMasterToBeFullyUp();

        return master;
    }

    protected Map<String, MasterMain> startDefaultMasters(final int numMasters, String... args) throws Exception {
        final Map<String, MasterMain> masters = Maps.newHashMap();

        for (int i = 0; i < numMasters; i++) {
            final String name = TEST_MASTER + i;
            final List<String> argsList = Lists.newArrayList(args);
            argsList.addAll(asList("--name", name));
            masters.put(name, startDefaultMaster(i, argsList.toArray(new String[argsList.size()])));
        }

        return masters;
    }

    protected void waitForMasterToBeFullyUp() throws Exception {
        log.debug("waitForMasterToBeFullyUp: beginning wait loop");
        Polling.await(WAIT_TIMEOUT_SECONDS, SECONDS, new Callable<Object>() {
            @Override
            public Object call() {
                try {
                    // While MasterService will start listening for http requests on the main and admin ports
                    // as soon as it is started (without waiting for ZK to be available), the Healthcheck
                    // registered for Zookeeper connectivity will cause the HealthcheckServlet to not return
                    // 200 OK until ZK is connected to (and even better, until *everything* is healthy).
                    final HttpGet request = new HttpGet(masterAdminEndpoint + "/healthcheck");

                    try (CloseableHttpResponse response = httpClient.execute(request)) {
                        final int status = response.getStatusLine().getStatusCode();
                        log.debug("waitForMasterToBeFullyUp: healthcheck endpoint returned {}", status);
                        return status == HttpStatus.SC_OK;
                    }
                } catch (Exception e) {
                    return null;
                }
            }
        });
    }

    protected void startDefaultMasterDontWaitForZK(final CuratorClientFactory curatorClientFactory, String... args)
            throws Exception {
        List<String> argsList = setupDefaultMaster(args);

        if (argsList == null) {
            return;
        }

        startMaster(curatorClientFactory, argsList.toArray(new String[argsList.size()]));
    }

    protected AgentMain startDefaultAgent(final String host, final String... args) throws Exception {
        if (isIntegration()) {
            checkArgument(args.length == 0, "cannot start default agent in integration test with arguments passed");
            return null;
        }

        final String stateDir = agentStateDirs.resolve(host).toString();
        final List<String> argsList = Lists.newArrayList("-vvvv", "--no-log-setup", "--no-http", "--name", host,
                "--docker=" + DOCKER_HOST, "--zk", zk.connectString(), "--zk-session-timeout", "100",
                "--zk-connection-timeout", "100", "--state-dir", stateDir, "--domain", "",
                "--port-range=" + dockerPortRange.lowerEndpoint() + ":" + dockerPortRange.upperEndpoint());
        argsList.addAll(asList(args));
        return startAgent(argsList.toArray(new String[argsList.size()]));
    }

    protected MasterMain startMaster(final Map<String, String> environmentVariables, final String... args)
            throws Exception {
        final MasterMain main = new MasterMain(environmentVariables, args);
        main.startAsync().awaitRunning();
        services.add(main);
        return main;
    }

    MasterMain startMaster(final CuratorClientFactory curatorClientFactory, final String... args) throws Exception {
        final MasterMain main = new MasterMain(curatorClientFactory, args);
        main.startAsync().awaitRunning();
        services.add(main);
        return main;
    }

    protected AgentMain startAgent(final String... args) throws Exception {
        final AgentMain main = new AgentMain(args);
        main.startAsync().awaitRunning();
        services.add(main);
        return main;
    }

    protected JobId createJob(final String name, final String version, final String image,
            final List<String> command) throws Exception {
        return createJob(name, version, image, command, EMPTY_ENV, EMPTY_PORTS, EMPTY_REGISTRATION);
    }

    protected JobId createJob(final String name, final String version, final String image,
            final List<String> command, final Date expires) throws Exception {
        return createJob(name, version, image, EMPTY_HOSTNAME, command, EMPTY_ENV, EMPTY_PORTS, EMPTY_REGISTRATION,
                EMPTY_GRACE_PERIOD, EMPTY_VOLUMES, expires);
    }

    protected JobId createJob(final String name, final String version, final String image,
            final List<String> command, final ImmutableMap<String, String> env) throws Exception {
        return createJob(name, version, image, command, env, EMPTY_PORTS, EMPTY_REGISTRATION);
    }

    protected JobId createJob(final String name, final String version, final String image,
            final List<String> command, final Map<String, String> env, final Map<String, PortMapping> ports)
            throws Exception {
        return createJob(name, version, image, command, env, ports, EMPTY_REGISTRATION);
    }

    protected JobId createJob(final String name, final String version, final String image,
            final List<String> command, final Map<String, String> env, final Map<String, PortMapping> ports,
            final Map<ServiceEndpoint, ServicePorts> registration) throws Exception {
        return createJob(name, version, image, command, env, ports, registration, EMPTY_GRACE_PERIOD,
                EMPTY_VOLUMES);
    }

    protected JobId createJob(final String name, final String version, final String image,
            final List<String> command, final Map<String, String> env, final Map<String, PortMapping> ports,
            final Map<ServiceEndpoint, ServicePorts> registration, final Integer gracePeriod,
            final Map<String, String> volumes) throws Exception {
        return createJob(name, version, image, EMPTY_HOSTNAME, command, env, ports, registration, gracePeriod,
                volumes, EMPTY_EXPIRES);
    }

    protected JobId createJob(final String name, final String version, final String image, final String hostname,
            final List<String> command, final Map<String, String> env, final Map<String, PortMapping> ports,
            final Map<ServiceEndpoint, ServicePorts> registration, final Integer gracePeriod,
            final Map<String, String> volumes, final Date expires) throws Exception {
        return createJob(Job.newBuilder().setName(name).setVersion(version).setImage(image).setHostname(hostname)
                .setCommand(command).setEnv(env).setPorts(ports).setRegistration(registration)
                .setGracePeriod(gracePeriod).setVolumes(volumes).setExpires(expires).build());
    }

    protected JobId createJob(final Job job) throws Exception {
        final String createOutput = createJobRawOutput(job);
        final String jobId = WHITESPACE.trimFrom(createOutput);

        return JobId.fromString(jobId);
    }

    protected String createJobRawOutput(final Job job) throws Exception {
        final String name = job.getId().getName();
        checkArgument(name.contains(testTag), "Job name must contain testTag to enable cleanup");

        final String serializedConfig = Json.asNormalizedString(job);
        final File configFile = temporaryFolder.newFile();
        Files.write(serializedConfig, configFile, Charsets.UTF_8);

        final List<String> args = ImmutableList.of("-q", "-f", configFile.getAbsolutePath());
        return cli("create", args);
    }

    protected void deployJob(final JobId jobId, final String host) throws Exception {
        deployJob(jobId, host, null);
    }

    protected void deployJob(final JobId jobId, final String host, final String token) throws Exception {
        final List<String> deployArgs = Lists.newArrayList(jobId.toString(), host);

        if (token != null) {
            deployArgs.addAll(ImmutableList.of("--token", token));
        }

        final String deployOutput = cli("deploy", deployArgs);
        assertThat(deployOutput, containsString(host + ": done"));

        final String output = cli("status", "--host", host, "--json");
        final Map<JobId, JobStatus> statuses = Json.readUnchecked(output,
                new TypeReference<Map<JobId, JobStatus>>() {
                });
        assertTrue(statuses.keySet().contains(jobId));
    }

    protected void undeployJob(final JobId jobId, final String host) throws Exception {
        final String undeployOutput = cli("undeploy", jobId.toString(), host);
        assertThat(undeployOutput, containsString(host + ": done"));

        final String output = cli("status", "--host", host, "--json");
        final Map<JobId, JobStatus> statuses = Json.readUnchecked(output,
                new TypeReference<Map<JobId, JobStatus>>() {
                });
        final JobStatus status = statuses.get(jobId);
        assertTrue(status == null || status.getDeployments().get(host) == null);
    }

    protected String startJob(final JobId jobId, final String host) throws Exception {
        return cli("start", jobId.toString(), host);
    }

    protected String stopJob(final JobId jobId, final String host) throws Exception {
        return cli("stop", jobId.toString(), host);
    }

    protected String deregisterHost(final String host) throws Exception {
        return cli("deregister", host, "--yes");
    }

    protected String cli(final String command, final Object... args) throws Exception {
        return cli(command, flatten(args));
    }

    protected String cli(final String command, final String... args) throws Exception {
        return cli(command, asList(args));
    }

    protected String cli(final String command, final List<String> args) throws Exception {
        final List<String> commands = asList(command, "-z", masterEndpoint(), "--no-log-setup");
        final List<String> allArgs = newArrayList(concat(commands, args));
        return main(allArgs).toString();
    }

    protected <T> T cliJson(final Class<T> klass, final String command, final String... args) throws Exception {
        return cliJson(klass, command, asList(args));
    }

    protected <T> T cliJson(final Class<T> klass, final String command, final List<String> args) throws Exception {
        final List<String> args0 = newArrayList("--json");
        args0.addAll(args);
        return Json.read(cli(command, args0), klass);
    }

    protected ByteArrayOutputStream main(final String... args) throws Exception {
        final ByteArrayOutputStream out = new ByteArrayOutputStream();
        final ByteArrayOutputStream err = new ByteArrayOutputStream();
        final CliMain main = new CliMain(new PrintStream(out), new PrintStream(err), args);
        main.run();
        return out;
    }

    protected ByteArrayOutputStream main(final Collection<String> args) throws Exception {
        return main(args.toArray(new String[args.size()]));
    }

    protected void awaitHostRegistered(final String name, final long timeout, final TimeUnit timeUnit)
            throws Exception {
        Polling.await(timeout, timeUnit, new Callable<Object>() {
            @Override
            public Object call() throws Exception {
                final String output = cli("hosts", "-q");
                return output.contains(name) ? true : null;
            }
        });
    }

    protected HostStatus awaitHostStatus(final String name, final HostStatus.Status status, final int timeout,
            final TimeUnit timeUnit) throws Exception {
        return Polling.await(timeout, timeUnit, new Callable<HostStatus>() {
            @Override
            public HostStatus call() throws Exception {
                final String output = cli("hosts", name, "--json");
                final Map<String, HostStatus> statuses;
                try {
                    statuses = Json.read(output, new TypeReference<Map<String, HostStatus>>() {
                    });
                } catch (IOException e) {
                    return null;
                }
                final HostStatus hostStatus = statuses.get(name);
                if (hostStatus == null) {
                    return null;
                }
                return (hostStatus.getStatus() == status) ? hostStatus : null;
            }
        });
    }

    protected TaskStatus awaitJobState(final HeliosClient client, final String host, final JobId jobId,
            final TaskStatus.State state, final int timeout, final TimeUnit timeunit) throws Exception {
        return Polling.await(timeout, timeunit, new Callable<TaskStatus>() {
            @Override
            public TaskStatus call() throws Exception {
                final HostStatus hostStatus = getOrNull(client.hostStatus(host));
                if (hostStatus == null) {
                    return null;
                }
                final TaskStatus taskStatus = hostStatus.getStatuses().get(jobId);
                return (taskStatus != null && taskStatus.getState() == state) ? taskStatus : null;
            }
        });
    }

    protected TaskStatus awaitJobThrottle(final HeliosClient client, final String host, final JobId jobId,
            final ThrottleState throttled, final int timeout, final TimeUnit timeunit) throws Exception {
        return Polling.await(timeout, timeunit, new Callable<TaskStatus>() {
            @Override
            public TaskStatus call() throws Exception {
                final HostStatus hostStatus = getOrNull(client.hostStatus(host));
                if (hostStatus == null) {
                    return null;
                }
                final TaskStatus taskStatus = hostStatus.getStatuses().get(jobId);
                return (taskStatus != null && taskStatus.getThrottled() == throttled) ? taskStatus : null;
            }
        });
    }

    protected void awaitHostRegistered(final HeliosClient client, final String host, final int timeout,
            final TimeUnit timeUnit) throws Exception {
        Polling.await(timeout, timeUnit, new Callable<HostStatus>() {
            @Override
            public HostStatus call() throws Exception {
                return getOrNull(client.hostStatus(host));
            }
        });
    }

    protected HostStatus awaitHostStatus(final HeliosClient client, final String host,
            final HostStatus.Status status, final int timeout, final TimeUnit timeUnit) throws Exception {
        return Polling.await(timeout, timeUnit, new Callable<HostStatus>() {
            @Override
            public HostStatus call() throws Exception {
                final HostStatus hostStatus = getOrNull(client.hostStatus(host));
                if (hostStatus == null) {
                    return null;
                }
                return (hostStatus.getStatus() == status) ? hostStatus : null;
            }
        });
    }

    protected HostStatus awaitHostStatusWithLabels(final HeliosClient client, final String host,
            final HostStatus.Status status, final int timeout, final TimeUnit timeUnit) throws Exception {
        return Polling.await(timeout, timeUnit, new Callable<HostStatus>() {
            @Override
            public HostStatus call() throws Exception {
                final HostStatus hostStatus = getOrNull(client.hostStatus(host));
                if (hostStatus == null || hostStatus.getLabels().size() == 0) {
                    return null;
                }
                return (hostStatus.getStatus() == status) ? hostStatus : null;
            }
        });
    }

    protected HostStatus awaitHostStatusWithHostInfo(final HeliosClient client, final String host,
            final HostStatus.Status status, final int timeout, final TimeUnit timeUnit) throws Exception {
        return Polling.await(timeout, timeUnit, new Callable<HostStatus>() {
            @Override
            public HostStatus call() throws Exception {
                final HostStatus hostStatus = getOrNull(client.hostStatus(host));
                if (hostStatus == null || hostStatus.getHostInfo() == null) {
                    return null;
                }
                return (hostStatus.getStatus() == status) ? hostStatus : null;
            }
        });
    }

    protected TaskStatus awaitTaskState(final JobId jobId, final String host, final TaskStatus.State state)
            throws Exception {
        return Polling.await(LONG_WAIT_SECONDS, SECONDS, new Callable<TaskStatus>() {
            @Override
            public TaskStatus call() throws Exception {
                final String output = cli("status", "--json", "--job", jobId.toString());
                final Map<JobId, JobStatus> statusMap;
                try {
                    statusMap = Json.read(output, new TypeReference<Map<JobId, JobStatus>>() {
                    });
                } catch (IOException e) {
                    return null;
                }
                final JobStatus status = statusMap.get(jobId);
                if (status == null) {
                    return null;
                }
                final TaskStatus taskStatus = status.getTaskStatuses().get(host);
                if (taskStatus == null) {
                    return null;
                }
                if (taskStatus.getState() != state) {
                    return null;
                }
                return taskStatus;
            }
        });
    }

    protected void awaitTaskGone(final HeliosClient client, final String host, final JobId jobId,
            final long timeout, final TimeUnit timeunit) throws Exception {
        Polling.await(timeout, timeunit, new Callable<Boolean>() {
            @Override
            public Boolean call() throws Exception {
                final HostStatus hostStatus = getOrNull(client.hostStatus(host));
                final TaskStatus taskStatus = hostStatus.getStatuses().get(jobId);
                final Deployment deployment = hostStatus.getJobs().get(jobId);
                return taskStatus == null && deployment == null ? true : null;
            }
        });
    }

    protected DeploymentGroupStatus awaitDeploymentGroupStatus(final HeliosClient client, final String name,
            final DeploymentGroupStatus.State state) throws Exception {
        return Polling.await(LONG_WAIT_SECONDS, SECONDS, new Callable<DeploymentGroupStatus>() {
            @Override
            public DeploymentGroupStatus call() throws Exception {
                final DeploymentGroupStatusResponse response = getOrNull(client.deploymentGroupStatus(name));

                if (response != null) {
                    final DeploymentGroupStatus status = response.getDeploymentGroupStatus();
                    if (status.getState().equals(state)) {
                        return status;
                    } else if (status.getState().equals(DeploymentGroupStatus.State.FAILED)) {
                        assertEquals(state, status.getState());
                    }
                }

                return null;
            }
        });
    }

    protected <T> T getOrNull(final ListenableFuture<T> future) throws ExecutionException, InterruptedException {
        return Futures.withFallback(future, new FutureFallback<T>() {
            @Override
            public ListenableFuture<T> create(@NotNull final Throwable t) throws Exception {
                return Futures.immediateFuture(null);
            }
        }).get();
    }

    protected String readLogFully(final ClientResponse logs) throws IOException {
        final LogReader logReader = new LogReader(logs.getEntityInputStream());
        StringBuilder stringBuilder = new StringBuilder();
        LogMessage logMessage;
        while ((logMessage = logReader.nextMessage()) != null) {
            stringBuilder.append(UTF_8.decode(logMessage.content()));
        }
        logReader.close();
        return stringBuilder.toString();
    }

    protected static void removeContainer(final DockerClient dockerClient, final String containerId)
            throws Exception {
        // Work around docker sometimes failing to remove a container directly after killing it
        Polling.await(1, MINUTES, new Callable<Object>() {
            @Override
            public Object call() throws Exception {
                try {
                    dockerClient.killContainer(containerId);
                } catch (DockerRequestException e) {
                    if (e.message().contains("is not running")) {
                        // Container already isn't running. So we continue.
                    } else {
                        throw e;
                    }
                }

                try {
                    dockerClient.removeContainer(containerId);
                    return true;
                } catch (ContainerNotFoundException e) {
                    // We're done here
                    return true;
                } catch (DockerException e) {
                    if ((e instanceof DockerRequestException) && ((DockerRequestException) e).message()
                            .contains("Driver btrfs failed to remove root filesystem")) {
                        // Workaround btrfs issue where removing containers throws an exception,
                        // but succeeds anyway.
                        return true;
                    } else {
                        return null;
                    }
                }
            }
        });
    }

    protected List<Container> listContainers(final DockerClient dockerClient, final String needle)
            throws DockerException, InterruptedException {
        final List<Container> containers = dockerClient.listContainers();
        final List<Container> matches = Lists.newArrayList();
        for (final Container container : containers) {
            if (container.names() != null) {
                for (final String name : container.names()) {
                    if (name.contains(needle)) {
                        matches.add(container);
                        break;
                    }
                }
            }
        }
        return matches;
    }

    protected List<String> flatten(final Object... values) {
        final Iterable<Object> valuesList = asList(values);
        return flatten(valuesList);
    }

    protected List<String> flatten(final Iterable<?> values) {
        final List<String> list = new ArrayList<>();
        for (Object value : values) {
            if (value instanceof Iterable) {
                list.addAll(flatten((Iterable<?>) value));
            } else if (value.getClass() == String[].class) {
                list.addAll(asList((String[]) value));
            } else if (value instanceof String) {
                list.add((String) value);
            } else {
                throw new IllegalArgumentException();
            }
        }
        return list;
    }

    protected void assertJobsEqual(final Map<JobId, Job> expected, final Map<JobId, Job> actual) {
        assertEquals(expected.size(), actual.size());
        for (final Map.Entry<JobId, Job> entry : actual.entrySet()) {
            assertJobEquals(expected.get(entry.getKey()), entry.getValue());
        }
    }

    protected void assertJobEquals(final Job expected, final Job actual) {
        final Builder expectedBuilder = expected.toBuilder();

        // hack to make sure that any environment variables that were folded into the created job
        // because of environment variables set at runtime on the test-running-agent are removed
        // from the actual when we assert the equality below
        final Builder actualBuilder = actual.toBuilder();
        final Map<String, String> metadata = Maps.newHashMap(actual.getMetadata());
        for (Map.Entry<String, String> entry : JobCreateCommand.DEFAULT_METADATA_ENVVARS.entrySet()) {
            final String envVar = entry.getKey();
            final String metadataKey = entry.getValue();
            final String envValue = System.getenv(envVar);
            if (envValue != null && actual.getMetadata().containsKey(metadataKey)
                    && actual.getMetadata().get(metadataKey).equals(envValue)) {
                metadata.remove(metadataKey);
            }
        }
        actualBuilder.setMetadata(metadata);

        // Remove created timestamp set by master
        actualBuilder.setCreated(null);

        // copy the hash
        expectedBuilder.setHash(actualBuilder.build().getId().getHash());

        assertEquals(expectedBuilder.build(), actualBuilder.build());
    }

    protected static String randomHexString() {
        return toHexString(ThreadLocalRandom.current().nextInt());
    }

    protected void resetAgentStateDir() throws IOException {
        agentStateDirs = temporaryFolder.newFolder(UUID.randomUUID().toString()).toPath();
    }
}