diff --git a/README.md b/README.md index 6b48d9d4d..44ea08b86 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 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. diff --git a/src/main/java/com/palantir/docker/compose/DockerCompositionBuilder.java b/src/main/java/com/palantir/docker/compose/DockerCompositionBuilder.java index 426cab203..f19c0b02e 100644 --- a/src/main/java/com/palantir/docker/compose/DockerCompositionBuilder.java +++ b/src/main/java/com/palantir/docker/compose/DockerCompositionBuilder.java @@ -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; @@ -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 { @@ -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 services, MultiServiceHealthCheck check) { + return waitingForServices(services, check, DEFAULT_TIMEOUT); + } + + public DockerCompositionBuilder waitingForServices(List services, MultiServiceHealthCheck check, Duration timeout) { + List 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; } diff --git a/src/main/java/com/palantir/docker/compose/connection/waiting/HealthChecks.java b/src/main/java/com/palantir/docker/compose/connection/waiting/HealthChecks.java index 6fd9191b2..0e8d98889 100644 --- a/src/main/java/com/palantir/docker/compose/connection/waiting/HealthChecks.java +++ b/src/main/java/com/palantir/docker/compose/connection/waiting/HealthChecks.java @@ -22,11 +22,11 @@ import java.util.function.Function; public class HealthChecks { - public static HealthCheck toRespondOverHttp(int internalPort, Function urlFunction) { + public static SingleServiceHealthCheck toRespondOverHttp(int internalPort, Function urlFunction) { return container -> container.portIsListeningOnHttp(internalPort, urlFunction); } - public static HealthCheck toHaveAllPortsOpen() { + public static SingleServiceHealthCheck toHaveAllPortsOpen() { return Container::areAllPortsOpen; } } diff --git a/src/main/java/com/palantir/docker/compose/connection/waiting/MultiServiceHealthCheck.java b/src/main/java/com/palantir/docker/compose/connection/waiting/MultiServiceHealthCheck.java new file mode 100644 index 000000000..05261e038 --- /dev/null +++ b/src/main/java/com/palantir/docker/compose/connection/waiting/MultiServiceHealthCheck.java @@ -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 containers); +} diff --git a/src/main/java/com/palantir/docker/compose/connection/waiting/ServiceWait.java b/src/main/java/com/palantir/docker/compose/connection/waiting/ServiceWait.java index 48e012f1f..1eca0da66 100644 --- a/src/main/java/com/palantir/docker/compose/connection/waiting/ServiceWait.java +++ b/src/main/java/com/palantir/docker/compose/connection/waiting/ServiceWait.java @@ -15,6 +15,7 @@ */ 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; @@ -22,25 +23,34 @@ 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 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 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> lastSuccessOrFailure = new AtomicReference<>(Optional.empty()); try { Awaitility.await() @@ -54,7 +64,7 @@ public void waitTillServiceIsUp() { private Callable weHaveSuccess(AtomicReference> lastSuccessOrFailure) { return () -> { - SuccessOrFailure successOrFailure = healthCheck.isServiceUp(service); + SuccessOrFailure successOrFailure = healthCheck.areServicesUp(containers); lastSuccessOrFailure.set(Optional.of(successOrFailure)); return successOrFailure.succeeded(); }; @@ -65,8 +75,16 @@ private String serviceDidNotStartupExceptionMessage(AtomicReference 1 ? "Containers" : "Container", + containerNames(), healthcheckFailureMessage); } + + private String containerNames() { + return containers.stream() + .map(Container::getContainerName) + .collect(joining(", ")); + + } } diff --git a/src/main/java/com/palantir/docker/compose/connection/waiting/HealthCheck.java b/src/main/java/com/palantir/docker/compose/connection/waiting/SingleServiceHealthCheck.java similarity index 94% rename from src/main/java/com/palantir/docker/compose/connection/waiting/HealthCheck.java rename to src/main/java/com/palantir/docker/compose/connection/waiting/SingleServiceHealthCheck.java index 83d1cb834..22c3cd5fb 100644 --- a/src/main/java/com/palantir/docker/compose/connection/waiting/HealthCheck.java +++ b/src/main/java/com/palantir/docker/compose/connection/waiting/SingleServiceHealthCheck.java @@ -19,6 +19,6 @@ import com.palantir.docker.compose.connection.Container; @FunctionalInterface -public interface HealthCheck { +public interface SingleServiceHealthCheck { SuccessOrFailure isServiceUp(Container container); } diff --git a/src/test/java/com/palantir/docker/compose/DockerCompositionIntegrationTest.java b/src/test/java/com/palantir/docker/compose/DockerCompositionIntegrationTest.java index ea38c9a84..f028ad2dd 100644 --- a/src/test/java/com/palantir/docker/compose/DockerCompositionIntegrationTest.java +++ b/src/test/java/com/palantir/docker/compose/DockerCompositionIntegrationTest.java @@ -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 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 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); + } + }); } } diff --git a/src/test/java/com/palantir/docker/compose/DockerCompositionTest.java b/src/test/java/com/palantir/docker/compose/DockerCompositionTest.java index 59c4b8ce7..ed243a873 100644 --- a/src/test/java/com/palantir/docker/compose/DockerCompositionTest.java +++ b/src/test/java/com/palantir/docker/compose/DockerCompositionTest.java @@ -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; @@ -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; @@ -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 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)); } @@ -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; } } diff --git a/src/test/java/com/palantir/docker/compose/connection/waiting/MultiServiceHealthCheckShould.java b/src/test/java/com/palantir/docker/compose/connection/waiting/MultiServiceHealthCheckShould.java new file mode 100644 index 000000000..ac8173356 --- /dev/null +++ b/src/test/java/com/palantir/docker/compose/connection/waiting/MultiServiceHealthCheckShould.java @@ -0,0 +1,54 @@ +/* + * 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.collect.ImmutableList; +import com.palantir.docker.compose.connection.Container; +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MultiServiceHealthCheckShould { + + private static final Container CONTAINER = mock(Container.class); + private static final Container OTHER_CONTAINER = mock(Container.class); + + private final SingleServiceHealthCheck delegate = mock(SingleServiceHealthCheck.class); + private final MultiServiceHealthCheck healthCheck = MultiServiceHealthCheck.fromSingleServiceHealthCheck(delegate); + + @Test public void + delegate_to_the_wrapped_single_service_health_check() { + when(delegate.isServiceUp(CONTAINER)).thenReturn(SuccessOrFailure.success()); + + assertThat( + healthCheck.areServicesUp(ImmutableList.of(CONTAINER)), + is(delegate.isServiceUp(CONTAINER))); + } + + @Test(expected = IllegalArgumentException.class) public void + throw_an_error_when_a_wrapped_health_check_is_passed_more_than_1_argument() { + healthCheck.areServicesUp(ImmutableList.of(CONTAINER, OTHER_CONTAINER)); + } + + @Test(expected = IllegalArgumentException.class) public void + throw_an_error_when_a_wrapped_health_check_is_passed_0_arguments() { + healthCheck.areServicesUp(ImmutableList.of(CONTAINER, OTHER_CONTAINER)); + } + +} diff --git a/src/test/java/com/palantir/docker/compose/connection/waiting/PortsHealthCheckShould.java b/src/test/java/com/palantir/docker/compose/connection/waiting/PortsHealthCheckShould.java index 70e64599c..2cfee13a9 100644 --- a/src/test/java/com/palantir/docker/compose/connection/waiting/PortsHealthCheckShould.java +++ b/src/test/java/com/palantir/docker/compose/connection/waiting/PortsHealthCheckShould.java @@ -26,7 +26,7 @@ import static org.mockito.Mockito.when; public class PortsHealthCheckShould { - private final HealthCheck healthCheck = HealthChecks.toHaveAllPortsOpen(); + private final SingleServiceHealthCheck healthCheck = HealthChecks.toHaveAllPortsOpen(); private final Container container = mock(Container.class); @Test diff --git a/src/test/java/com/palantir/docker/compose/logging/DockerCompositionLoggingIntegrationTest.java b/src/test/java/com/palantir/docker/compose/logging/DockerCompositionLoggingIntegrationTest.java index a92e65e5d..fccc28247 100644 --- a/src/test/java/com/palantir/docker/compose/logging/DockerCompositionLoggingIntegrationTest.java +++ b/src/test/java/com/palantir/docker/compose/logging/DockerCompositionLoggingIntegrationTest.java @@ -26,10 +26,8 @@ import static com.palantir.docker.compose.connection.waiting.HealthChecks.toHaveAllPortsOpen; import static com.palantir.docker.compose.matchers.IOMatchers.file; -import static com.palantir.docker.compose.matchers.IOMatchers.fileWithName; import static com.palantir.docker.compose.matchers.IOMatchers.matchingPattern; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.arrayContainingInAnyOrder; import static org.hamcrest.core.Is.is; public class DockerCompositionLoggingIntegrationTest { @@ -56,7 +54,6 @@ public void logs_can_be_saved_to_a_directory() throws IOException, InterruptedEx } finally { loggingComposition.after(); } - assertThat(logFolder.getRoot().listFiles(), arrayContainingInAnyOrder(fileWithName("db.log"), fileWithName("db2.log"))); assertThat(new File(logFolder.getRoot(), "db.log"), is(file(matchingPattern(".*Attaching to \\w+_db_1.*")))); assertThat(new File(logFolder.getRoot(), "db2.log"), is(file(matchingPattern(".*Attaching to \\w+_db2_1.*")))); } diff --git a/src/test/resources/docker-compose.yaml b/src/test/resources/docker-compose.yaml index 6f7b54949..b924c8da1 100644 --- a/src/test/resources/docker-compose.yaml +++ b/src/test/resources/docker-compose.yaml @@ -15,4 +15,21 @@ db2: - "POSTGRES_PASSWORD=palantir" ports: - "5432" - \ No newline at end of file + +db3: + image: kiasaki/alpine-postgres + environment: + - "POSTGRES_DB=source" + - "POSTGRES_USER=palantir" + - "POSTGRES_PASSWORD=palantir" + ports: + - "5432" + +db4: + image: kiasaki/alpine-postgres + environment: + - "POSTGRES_DB=source" + - "POSTGRES_USER=palantir" + - "POSTGRES_PASSWORD=palantir" + ports: + - "5432"