Skip to content

Commit

Permalink
Merge pull request #45 from palantir/feature/allow-healthchecks-on-mu…
Browse files Browse the repository at this point in the history
…ltiple-containers

Feature/allow healthchecks on multiple containers
  • Loading branch information
joelea committed Apr 6, 2016
2 parents ff3ae48 + 152ed8b commit 702ad9a
Show file tree
Hide file tree
Showing 12 changed files with 231 additions and 28 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public class DockerCompositionTest {
.waitingForService("db", HealthChecks.toHaveAllPortsOpen())
.waitingForService("web", HealthChecks.toRespondOverHttp(8080, (port) -> "https://" + port.getIp() + ":" + port.getExternalPort()))
.waitingForService("other", (container) -> customServiceCheck(container), Duration.standardMinutes(2))
.waitingForServices(ImmutableList.of("node1", "node2"), toBeHealthyAsACluster())
.build();

@Test
Expand All @@ -87,7 +88,10 @@ public class DockerCompositionTest {
}
```

The entrypoint method `waitingForService(String container, HealthCheck check[, Duration timeout])` will make sure the healthcheck passes for that container before the tests start. We provide 2 default healthChecks in the HealthChecks class:
The entrypoint method `waitingForService(String container, SingleServiceHealthCheck check[, Duration timeout])` will make sure the healthcheck passes for that container before the tests start.
The entrypoint method `waitingForServices(List<String> containers, MultiServiceHealthCheck check[, Duration timeout])` will make sure the healthcheck passes for the cluster of containers before the tests start.

We provide 2 default healthChecks in the HealthChecks class:

1. `toHaveAllPortsOpen` - this waits till all ports can be connected to that are exposed on the container
2. `toRespondOverHttp` - which waits till the specified URL responds to a HTTP request.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
*/
package com.palantir.docker.compose;

import com.palantir.docker.compose.connection.Container;
import com.palantir.docker.compose.connection.ContainerCache;
import com.palantir.docker.compose.connection.waiting.HealthCheck;
import com.palantir.docker.compose.connection.waiting.MultiServiceHealthCheck;
import com.palantir.docker.compose.connection.waiting.ServiceWait;
import com.palantir.docker.compose.connection.waiting.SingleServiceHealthCheck;
import com.palantir.docker.compose.execution.DockerCompose;
import com.palantir.docker.compose.logging.DoNothingLogCollector;
import com.palantir.docker.compose.logging.FileLogCollector;
Expand All @@ -27,6 +29,7 @@
import java.util.ArrayList;
import java.util.List;

import static java.util.stream.Collectors.toList;
import static org.joda.time.Duration.standardMinutes;

public class DockerCompositionBuilder {
Expand All @@ -42,11 +45,23 @@ public DockerCompositionBuilder(DockerCompose dockerComposeProcess) {
this.containers = new ContainerCache(dockerComposeProcess);
}

public DockerCompositionBuilder waitingForService(String serviceName, HealthCheck check) {
public DockerCompositionBuilder waitingForService(String serviceName, SingleServiceHealthCheck check) {
return waitingForService(serviceName, check, DEFAULT_TIMEOUT);
}

public DockerCompositionBuilder waitingForService(String serviceName, HealthCheck check, Duration timeout) {
public DockerCompositionBuilder waitingForServices(List<String> services, MultiServiceHealthCheck check) {
return waitingForServices(services, check, DEFAULT_TIMEOUT);
}

public DockerCompositionBuilder waitingForServices(List<String> services, MultiServiceHealthCheck check, Duration timeout) {
List<Container> containersToWaitFor = services.stream()
.map(containers::get)
.collect(toList());
serviceWaits.add(new ServiceWait(containersToWaitFor, check, timeout));
return this;
}

public DockerCompositionBuilder waitingForService(String serviceName, SingleServiceHealthCheck check, Duration timeout) {
serviceWaits.add(new ServiceWait(containers.get(serviceName), check, timeout));
return this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@
import java.util.function.Function;

public class HealthChecks {
public static HealthCheck toRespondOverHttp(int internalPort, Function<DockerPort, String> urlFunction) {
public static SingleServiceHealthCheck toRespondOverHttp(int internalPort, Function<DockerPort, String> urlFunction) {
return container -> container.portIsListeningOnHttp(internalPort, urlFunction);
}

public static HealthCheck toHaveAllPortsOpen() {
public static SingleServiceHealthCheck toHaveAllPortsOpen() {
return Container::areAllPortsOpen;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2016 Palantir Technologies, Inc. All rights reserved.
*
* 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.palantir.docker.compose.connection.waiting;

import com.google.common.base.Preconditions;
import com.palantir.docker.compose.connection.Container;

import java.util.List;

import static com.google.common.collect.Iterables.getOnlyElement;

@FunctionalInterface
public interface MultiServiceHealthCheck {
static MultiServiceHealthCheck fromSingleServiceHealthCheck(SingleServiceHealthCheck healthCheck) {
return containers -> {
Preconditions.checkArgument(containers.size() == 1, "Trying to run a single container health check on containers " + containers);
return healthCheck.isServiceUp(getOnlyElement(containers));
};
}

SuccessOrFailure areServicesUp(List<Container> containers);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,42 @@
*/
package com.palantir.docker.compose.connection.waiting;

import com.google.common.collect.ImmutableList;
import com.jayway.awaitility.Awaitility;
import com.jayway.awaitility.core.ConditionTimeoutException;
import com.palantir.docker.compose.connection.Container;
import org.joda.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import static java.util.stream.Collectors.joining;

public class ServiceWait {
private static final Logger log = LoggerFactory.getLogger(ServiceWait.class);
private final Container service;
private final HealthCheck healthCheck;
private final List<Container> containers;
private final MultiServiceHealthCheck healthCheck;
private final Duration timeout;

public ServiceWait(Container service, HealthCheck healthCheck, Duration timeout) {
this.service = service;
public ServiceWait(Container service, SingleServiceHealthCheck healthCheck, Duration timeout) {
this.containers = ImmutableList.of(service);
this.healthCheck = MultiServiceHealthCheck.fromSingleServiceHealthCheck(healthCheck);
this.timeout = timeout;
}

public ServiceWait(List<Container> containers, MultiServiceHealthCheck healthCheck, Duration timeout) {
this.containers = containers;
this.healthCheck = healthCheck;
this.timeout = timeout;
}

public void waitTillServiceIsUp() {
log.debug("Waiting for service '{}'", service);
log.debug("Waiting for services [{}]", containerNames());
final AtomicReference<Optional<SuccessOrFailure>> lastSuccessOrFailure = new AtomicReference<>(Optional.empty());
try {
Awaitility.await()
Expand All @@ -54,7 +64,7 @@ public void waitTillServiceIsUp() {

private Callable<Boolean> weHaveSuccess(AtomicReference<Optional<SuccessOrFailure>> lastSuccessOrFailure) {
return () -> {
SuccessOrFailure successOrFailure = healthCheck.isServiceUp(service);
SuccessOrFailure successOrFailure = healthCheck.areServicesUp(containers);
lastSuccessOrFailure.set(Optional.of(successOrFailure));
return successOrFailure.succeeded();
};
Expand All @@ -65,8 +75,16 @@ private String serviceDidNotStartupExceptionMessage(AtomicReference<Optional<Suc
.flatMap(SuccessOrFailure::toOptionalFailureMessage)
.orElse("The healthcheck did not finish before the timeout");

return String.format("Container '%s' failed to pass startup check:%n%s",
service.getContainerName(),
return String.format("%s '%s' failed to pass startup check:%n%s",
containers.size() > 1 ? "Containers" : "Container",
containerNames(),
healthcheckFailureMessage);
}

private String containerNames() {
return containers.stream()
.map(Container::getContainerName)
.collect(joining(", "));

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@
import com.palantir.docker.compose.connection.Container;

@FunctionalInterface
public interface HealthCheck {
public interface SingleServiceHealthCheck {
SuccessOrFailure isServiceUp(Container container);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,44 +15,87 @@
*/
package com.palantir.docker.compose;

import com.google.common.collect.ImmutableList;
import com.palantir.docker.compose.connection.Container;
import com.palantir.docker.compose.connection.waiting.MultiServiceHealthCheck;
import com.palantir.docker.compose.connection.waiting.SuccessOrFailure;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;

import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;

import static com.google.common.base.Throwables.propagate;
import static com.palantir.docker.compose.connection.waiting.HealthChecks.toHaveAllPortsOpen;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.Is.is;

public class DockerCompositionIntegrationTest {

private static final List<String> CONTAINERS = ImmutableList.of("db", "db2", "db3", "db4");

@Rule
public DockerComposition composition = DockerComposition.of("src/test/resources/docker-compose.yaml")
.waitingForService("db", toHaveAllPortsOpen())
.waitingForService("db2", toHaveAllPortsOpen())
.waitingForServices(ImmutableList.of("db3", "db4"), toAllHaveAllPortsOpen())
.build();

private MultiServiceHealthCheck toAllHaveAllPortsOpen() {
return containers -> {
boolean healthy = containers.stream()
.map(Container::areAllPortsOpen)
.allMatch(SuccessOrFailure::succeeded);

return SuccessOrFailure.fromBoolean(healthy, "");
};
}

@Rule
public ExpectedException exception = ExpectedException.none();
@Rule
public TemporaryFolder logFolder = new TemporaryFolder();

private void forEachContainer(Consumer<String> consumer) {
CONTAINERS.forEach(consumer);
}

@Test
public void should_run_docker_compose_up_using_the_specified_docker_compose_file_to_bring_postgres_up() throws InterruptedException, IOException {
assertThat(composition.portOnContainerWithExternalMapping("db", 5433).isListeningNow(), is(true));
forEachContainer((container) -> {
try {
assertThat(composition.portOnContainerWithExternalMapping("db", 5433).isListeningNow(), is(true));
} catch (IOException | InterruptedException e) {
propagate(e);
}
});
}

@Test
public void after_test_is_executed_the_launched_postgres_container_is_no_longer_listening() throws IOException, InterruptedException {
composition.after();
assertThat(composition.portOnContainerWithExternalMapping("db", 5433).isListeningNow(), is(false));

forEachContainer(container -> {
try {
assertThat(composition.portOnContainerWithExternalMapping("db", 5433).isListeningNow(), is(false));
} catch (IOException | InterruptedException e) {
propagate(e);
}
});
}

@Test
public void can_access_external_port_for_internal_port_of_machine() throws IOException, InterruptedException {
assertThat(composition.portOnContainerWithInternalMapping("db", 5432).isListeningNow(), is(true));
forEachContainer(container -> {
try {
assertThat(composition.portOnContainerWithInternalMapping("db", 5432).isListeningNow(), is(true));
} catch (IOException | InterruptedException e) {
propagate(e);
}
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
*/
package com.palantir.docker.compose;

import com.google.common.collect.ImmutableList;
import com.palantir.docker.compose.configuration.MockDockerEnvironment;
import com.palantir.docker.compose.connection.Container;
import com.palantir.docker.compose.connection.ContainerNames;
import com.palantir.docker.compose.connection.DockerPort;
import com.palantir.docker.compose.connection.waiting.HealthCheck;
import com.palantir.docker.compose.connection.waiting.MultiServiceHealthCheck;
import com.palantir.docker.compose.connection.waiting.SingleServiceHealthCheck;
import com.palantir.docker.compose.connection.waiting.SuccessOrFailure;
import com.palantir.docker.compose.execution.DockerCompose;
import org.apache.commons.io.IOUtils;
Expand All @@ -31,6 +33,7 @@
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
Expand Down Expand Up @@ -80,16 +83,30 @@ public void docker_compose_kill_and_rm_are_called_after_tests_are_run() throws I
public void docker_compose_wait_for_service_passes_when_check_is_true() throws IOException, InterruptedException {
AtomicInteger timesCheckCalled = new AtomicInteger(0);
withComposeExecutableReturningContainerFor("db");
HealthCheck checkCalledOnce = (container) -> SuccessOrFailure.fromBoolean(timesCheckCalled.incrementAndGet() == 1, "not called once yet");
SingleServiceHealthCheck checkCalledOnce = (container) -> SuccessOrFailure.fromBoolean(timesCheckCalled.incrementAndGet() == 1, "not called once yet");
dockerComposition.waitingForService("db", checkCalledOnce).build().before();
assertThat(timesCheckCalled.get(), is(1));
}

@Test
public void docker_compose_wait_for_service_waits_multiple_services() throws IOException, InterruptedException {
Container db1 = withComposeExecutableReturningContainerFor("db1");
Container db2 = withComposeExecutableReturningContainerFor("db2");
List<Container> containers = ImmutableList.of(db1, db2);

MultiServiceHealthCheck healthCheck = mock(MultiServiceHealthCheck.class);
when(healthCheck.areServicesUp(containers)).thenReturn(SuccessOrFailure.success());

dockerComposition.waitingForServices(ImmutableList.of("db1", "db2"), healthCheck).build().before();

verify(healthCheck).areServicesUp(containers);
}

@Test
public void docker_compose_wait_for_service_passes_when_check_is_true_after_being_false() throws IOException, InterruptedException {
AtomicInteger timesCheckCalled = new AtomicInteger(0);
withComposeExecutableReturningContainerFor("db");
HealthCheck checkCalledTwice = (container) -> SuccessOrFailure.fromBoolean(timesCheckCalled.incrementAndGet() == 2, "not called twice yet");
SingleServiceHealthCheck checkCalledTwice = (container) -> SuccessOrFailure.fromBoolean(timesCheckCalled.incrementAndGet() == 2, "not called twice yet");
dockerComposition.waitingForService("db", checkCalledTwice).build().before();
assertThat(timesCheckCalled.get(), is(2));
}
Expand Down Expand Up @@ -169,8 +186,10 @@ public void logs_can_be_saved_to_a_directory_while_containers_are_running() thro
assertThat(new File(logLocation, "db.log"), is(fileContainingString("db log")));
}

public void withComposeExecutableReturningContainerFor(String containerName) {
when(dockerCompose.container(containerName)).thenReturn(new Container(containerName, dockerCompose));
public Container withComposeExecutableReturningContainerFor(String containerName) {
final Container container = new Container(containerName, dockerCompose);
when(dockerCompose.container(containerName)).thenReturn(container);
return container;
}

}
Loading

0 comments on commit 702ad9a

Please sign in to comment.