diff --git a/README.md b/README.md index a1bcf48..b392497 100644 --- a/README.md +++ b/README.md @@ -800,6 +800,8 @@ To check if the test container is running, call the `isRunning` method: var isRunning = container.isRunning(); ``` +`start` waits until the database answers a ping, for up to 30 seconds from creating the container. Pulling the image does not count. If the container runs, but the database does not become reachable in time, `start` replaces the container with a new one, up to 3 containers in total. If Docker fails, or if the container exits, `start` fails at once, and in the latter case names the last lines of the container's log. Whenever a container fails to start, `start` removes it. + To get a client with options of your own, e.g. the data mapper of your application, hand them to `getClient`: ```java @@ -834,6 +836,13 @@ var container = new Container() .withApiToken("secret"); ``` +To change how long `start` waits for the database in each container, call the `withStartupTimeout` method. The default is 30 seconds: + +```java +var container = new Container() + .withStartupTimeout(Duration.ofSeconds(60)); +``` + If you want to sign events, call the `withSigningKey` method. This generates a new signing and verification key pair, and hands the signing key to the container: ```java diff --git a/eventsourcingdb-testcontainers/build.gradle.kts b/eventsourcingdb-testcontainers/build.gradle.kts index d319796..d10ed6b 100644 --- a/eventsourcingdb-testcontainers/build.gradle.kts +++ b/eventsourcingdb-testcontainers/build.gradle.kts @@ -20,3 +20,9 @@ dependencies { api(project(":eventsourcingdb")) api(libs.testcontainers) } + +// Testcontainers reuses containers only if the environment allows it, which +// the tests of reuse need. It affects only containers that ask for reuse. +tasks.test { + environment("TESTCONTAINERS_REUSE_ENABLE", "true") +} diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java new file mode 100644 index 0000000..aea0264 --- /dev/null +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Attempt.java @@ -0,0 +1,89 @@ +package io.thenativeweb.eventsourcingdb.testcontainers; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.function.IntPredicate; +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; +import org.testcontainers.containers.ContainerLaunchException; + +// Attempt is one attempt of Container.start() to start a container. Its clock +// for the startup timeout starts once it is created, which is right before +// Testcontainers asks Docker to create the container, so that pulling the +// image does not count. While Testcontainers starts the container, Container +// notes here what it learns about the container, so that an attempt that +// failed can tell whether to replace the container. +final class Attempt { + // How long the wait for the database pauses between two pings. + private static final Duration POLL_INTERVAL = Duration.ofMillis(100); + + // The id of the attempt, which the label of its container carries. + final String id = UUID.randomUUID().toString(); + + private final long deadline; + + // Whether the wait for the database began. Testcontainers begins it only + // once Docker published the port of the container. + boolean hasWaitBegun; + + // Whether the wait for the database ran out of time. + boolean hasRunOutOfTime; + + // Whether the container was running when Testcontainers gave up on it. + boolean wasRunning; + + // The last lines of the log of the container, if it exited. + @Nullable + String exitLog; + + Attempt(Duration startupTimeout) { + // Converting saturates, so that a startup timeout too long to count in + // nanoseconds does not fail. + deadline = System.nanoTime() + TimeUnit.NANOSECONDS.convert(startupTimeout); + } + + // Waits until the database answers a ping, until the container exits, or + // until the startup timeout runs out. It observes the container anew for + // every ping, including the port that Docker published for the database, + // as Docker sometimes publishes it late. A failure to observe the + // container is one of Docker, which ends the wait at once, and does not + // count as running out of time. That includes an interrupt, which cuts + // the call to Docker short. + void waitUntilReachable(Supplier observe, IntPredicate answersPing) { + hasWaitBegun = true; + + while (true) { + var observation = observe.get(); + if (observation.hasExited()) { + throw new ContainerLaunchException("the container exited"); + } + if (System.nanoTime() - deadline >= 0) { + hasRunOutOfTime = true; + throw new ContainerLaunchException("the database did not become reachable in time"); + } + + var mappedPort = observation.mappedPort(); + if (mappedPort != null && answersPing.test(mappedPort)) { + return; + } + + LockSupport.parkNanos(POLL_INTERVAL.toNanos()); + } + } + + // Tells whether the container was running, but the database in it did + // not become reachable in time. That is the case if the wait for the + // database ran out of time, and also if the container was running when + // Testcontainers gave up waiting for Docker to publish its port, which it + // does after a fixed time of its own. + boolean isUnreachable() { + return hasRunOutOfTime || (wasRunning && !hasWaitBegun); + } + + // Observation is what a look at the container tells: whether it exited, + // and the port on the host that Docker published for the database, if it + // did yet. + record Observation(boolean hasExited, @Nullable Integer mappedPort) {} +} diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index e9843d2..b48bc83 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -2,26 +2,42 @@ import static java.nio.charset.StandardCharsets.UTF_8; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Ports; import io.thenativeweb.eventsourcingdb.Client; import io.thenativeweb.eventsourcingdb.ClientOptions; +import java.math.BigDecimal; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; import java.util.function.Consumer; -import java.util.function.Function; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.containers.wait.strategy.WaitStrategy; -import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import org.testcontainers.images.builder.Transferable; import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.DockerStatus; +import org.testcontainers.utility.TestcontainersConfiguration; /** * An EventSourcingDB instance in a Docker container, for integration tests. @@ -47,27 +63,53 @@ public final class Container extends GenericContainer { private static final String IMAGE_NAME = "thenativeweb/eventsourcingdb"; private static final String SIGNING_KEY_PATH = "/etc/esdb/signing-key.pem"; - // How many times start() starts the container at most, as it replaces each - // container whose database does not become reachable. - private static final int MAX_START_ATTEMPTS = 3; + // How many containers start() starts at most, as it replaces each + // container that runs, but in which the database does not become + // reachable within the startup timeout. + static final int MAX_START_ATTEMPTS = 3; + + // The label that start() gives the container of each attempt, with the id + // of the attempt as its value, so that the containers of an attempt that + // failed can be found and removed, even one that Testcontainers does not + // know of. + static final String START_LABEL = "io.eventsourcingdb.container-start"; + + // How many lines from the end of its log the exception names for a + // container that exited. + static final int LOG_TAIL_LINES = 20; + + // How long a ping of the database may take before it counts as + // unanswered. + private static final Duration PING_TIMEOUT = Duration.ofSeconds(1); + + // How long after an interrupt ended a start the containers of its attempt + // are looked for once more. The interrupt cuts a call to Docker short, and + // Docker may still create a container for a call to create one that was + // cut short, after the start looked for the containers to remove. + static final Duration LATE_REMOVAL_DELAY = Duration.ofSeconds(5); private int internalPort = 3000; private String apiToken = "secret"; private @Nullable KeyPair signingKeyPair; - // Starts the container once. It is a field only so that tests can replace - // it. - Consumer starter = Container::startOnce; + // How long each attempt waits for the database to become reachable. It + // is not private only so that tests can read its default. + Duration startupTimeout = Duration.ofSeconds(30); + + // The attempt that start() makes right now, if any. The methods that + // Testcontainers calls while it starts the container note in it what they + // learn about the container. + private @Nullable Attempt attempt; - // Waits until the database answers pings on the given port inside the - // container. It is a field only so that tests can replace it. - Function pingWait = - port -> Wait.forHttp("/api/v1/ping").forPort(port).withStartupTimeout(Duration.ofSeconds(10)); + // Removes the containers that carry the label of the attempt with the + // given id. It is a field only so that tests can replace it. + Consumer containerRemover = Container::removeContainersWithLabel; - // Whether the container was running when the wait for pings failed. Once - // the wait fails, Testcontainers stops the container before it reports - // the failure, so this can only be told while the wait fails. - private volatile boolean wasUnreachableWhileRunning; + // Tells whether the environment allows Testcontainers to reuse + // containers. It is a field only so that tests can replace it, as the + // environment can not change while they run. + BooleanSupplier environmentSupportsReuse = + () -> TestcontainersConfiguration.getInstance().environmentSupportsReuse(); /** * Creates a container that uses the {@code latest} tag of the official EventSourcingDB image, port 3000 inside the @@ -122,6 +164,25 @@ public Container withSigningKey() { return this; } + /** + * Sets how long {@link #start()} waits for the instance in a container to answer pings, from creating the + * container on. Pulling the image does not count. The default is 30 seconds. + * + * @param startupTimeout how long to wait for the instance in a container to answer pings + * @return this container + * @throws IllegalArgumentException if the startup timeout is zero or negative + */ + @Override + public Container withStartupTimeout(Duration startupTimeout) { + // A startup timeout of zero or less would fail every attempt at once. + if (startupTimeout.isZero() || startupTimeout.isNegative()) { + throw new IllegalArgumentException("startup timeout must be positive"); + } + + this.startupTimeout = startupTimeout; + return this; + } + // Applies the configuration right before the container starts, so that the // with methods may be called in any order. @Override @@ -137,7 +198,6 @@ protected void configure() { String.valueOf(internalPort))); withExposedPorts(internalPort); - waitingFor(new NotingWait(pingWait.apply(internalPort))); if (signingKeyPair != null) { withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH); @@ -157,73 +217,282 @@ private static byte[] pem(PrivateKey privateKey) { } /** - * Starts the container, and waits until the instance answers pings. + * Starts the container, and waits until the instance answers pings, for the startup timeout at most. * - *

Docker Desktop sometimes does not make the port it maps a container to reachable from the host. If the - * database in a running container does not become reachable, the container is replaced by a new one, up to three - * times. A container that fails to start is removed either way. + *

Docker sometimes publishes the port of a container late, or does not make it reachable from the host at all. + * If the container runs, but the instance does not answer pings within the startup timeout, the container is + * replaced by a new one, up to three containers in total. If the container exits before the instance answers + * pings, or if Docker fails, the start fails at once. A container that exits before Docker publishes its port + * fails only once Testcontainers stops waiting for the port, which takes about five seconds. Either way, the + * containers of a start that failed are removed. While Testcontainers reuses containers, as + * {@link #withReuse(boolean)} and the environment ask for, the start neither labels, nor replaces, nor removes a + * container of its own accord, just as Testcontainers does not. + * + *

If the calling thread is interrupted before the instance answers pings, the start gives up, removes the + * containers of its attempt, and throws an {@link IllegalStateException}, and the interrupt status stays set. As + * Docker may still create a container for a call that the interrupt cut short, the start looks for containers to + * remove once more a few seconds later, in the background. Testcontainers pulls the image again after an interrupt, + * though, and clears the interrupt status, so an interrupt while it pulls the image can get lost. * * @throws IllegalStateException if the database did not become reachable in any of three containers * @throws RuntimeException if the container does not start for another reason, e.g. because the image does not * exist + * @see #withStartupTimeout(Duration) */ @Override public void start() { - for (var attempt = 1; ; attempt++) { - wasUnreachableWhileRunning = false; - try { - starter.accept(this); + // A thread that is interrupted already does not even pull the image. + if (Thread.currentThread().isInterrupted()) { + throw interrupted(null); + } + + // Pulling the image does not count toward the startup timeout, so it + // happens before the first attempt. Testcontainers keeps the image it + // resolved, so the attempts do not pull it again. + try { + getDockerImageName(); + } catch (RuntimeException ex) { + throw wasInterrupted(ex) ? interrupted(ex) : dockerFailed(ex); + } + + var isReused = isShouldBeReused() && environmentSupportsReuse.getAsBoolean(); + var removalFailures = new ArrayList(); + for (var attemptNumber = 1; ; attemptNumber++) { + var currentAttempt = new Attempt(startupTimeout); + var failure = startContainerOf(currentAttempt, isReused); + if (failure.isEmpty()) { return; - } catch (RuntimeException ex) { - // A container that was running when its wait for pings failed - // is one whose database can not be reached, so it is replaced - // by a new one. Every other failure is thrown at once. Either - // way, the container that failed to start is removed, so that - // it is not left behind. - stop(); - - if (!wasUnreachableWhileRunning) { - throw ex; - } - if (attempt == MAX_START_ATTEMPTS) { - throw new IllegalStateException( - "failed to start container, the database did not become reachable in " - + MAX_START_ATTEMPTS - + " attempts", - ex); - } + } + + var isInterrupted = wasInterrupted(failure.get()); + + // Testcontainers' reuse keeps containers on purpose, so the start + // leaves removing them to Testcontainers then. + if (!isReused) { + removeContainersOf(currentAttempt, isInterrupted).ifPresent(removalFailures::add); + } + + // Once the caller was interrupted, the start gives up, whatever + // else made the attempt fail. + if (isInterrupted) { + throw failed(interrupted(failure.get()), removalFailures); + } + + var exitLog = currentAttempt.exitLog; + if (exitLog != null) { + throw failed(exited(exitLog, failure.get()), removalFailures); + } + if (isReused || !currentAttempt.isUnreachable()) { + throw failed(dockerFailed(failure.get()), removalFailures); + } + if (attemptNumber == MAX_START_ATTEMPTS) { + throw failed(unreachable(failure.get()), removalFailures); } } } - void startOnce() { - super.start(); + // Tells whether the thread was interrupted, and clears its interrupt + // status, so that the interrupt does not cut the cleanup that follows + // short as well. Testcontainers clears the status itself in some places, + // and reports the interrupt as the cause of its failure instead. + private static boolean wasInterrupted(Throwable failure) { + var hadInterruptStatus = Thread.interrupted(); + return hadInterruptStatus + || Stream.iterate(failure, Objects::nonNull, Throwable::getCause) + .anyMatch(InterruptedException.class::isInstance); } - // NotingWait waits as the given wait does, and notes whether the - // container was still running if that wait fails. - final class NotingWait implements WaitStrategy { - private final WaitStrategy wait; + // Starts the container of the given attempt with Testcontainers, and + // returns the failure if that fails. A container that Testcontainers + // reuses gets no label, which would keep Testcontainers from finding it + // again. Testcontainers throws checked exceptions without declaring them, + // e.g. if a container it depends on fails, so the failure is any + // exception. + private Optional startContainerOf(Attempt currentAttempt, boolean isReused) { + attempt = currentAttempt; + if (!isReused) { + withLabel(START_LABEL, currentAttempt.id); + } - NotingWait(WaitStrategy wait) { - this.wait = wait; + try { + super.start(); + return Optional.empty(); + } catch (Exception ex) { + return Optional.of(ex); + } finally { + attempt = null; } + } - @Override - public void waitUntilReady(WaitStrategyTarget target) { - try { - wait.waitUntilReady(target); - } catch (RuntimeException ex) { - wasUnreachableWhileRunning = target.isRunning(); - throw ex; - } + // Testcontainers calls this once Docker published the port of the + // container, to wait until the container is ready. + @Override + protected void waitUntilContainerStarted() { + try (var httpClient = + HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()) { + Objects.requireNonNull(attempt) + .waitUntilReachable( + this::observe, + port -> answersPing( + httpClient, URI.create("http://" + super.getHost() + ":" + port + "/api/v1/ping"))); + } + } + + // Looks at the container as Docker sees it right now, rather than as + // Testcontainers saw it when it started the container. + private Attempt.Observation observe() { + var containerInfo = getCurrentContainerInfo(); + var bindings = + containerInfo.getNetworkSettings().getPorts().getBindings().get(ExposedPort.tcp(internalPort)); + + return new Attempt.Observation( + DockerStatus.isContainerStopped(containerInfo.getState()), + Stream.ofNullable(bindings) + .flatMap(Arrays::stream) + .filter(Objects::nonNull) + .map(Ports.Binding::getHostPortSpec) + .map(Integer::valueOf) + .findFirst() + .orElse(null)); + } + + // Tells whether the database answers a ping with 200 OK. Any other + // answer, and no answer at all, mean that it is not reachable yet. + static boolean answersPing(HttpClient httpClient, URI pingUrl) { + var request = HttpRequest.newBuilder(pingUrl).timeout(PING_TIMEOUT).build(); + + try { + return httpClient + .sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .join() + .statusCode() + == 200; + } catch (CompletionException ex) { + return false; } + } + + // Testcontainers calls this before it removes a container, also one whose + // start failed, before it reports the failure. Only until then can it be + // told whether the container was running or exited, and what it logged. + @Override + protected void containerIsStopping(InspectContainerResponse containerInfo) { + var currentAttempt = attempt; + if (currentAttempt == null) { + return; + } + + var state = getCurrentContainerInfo().getState(); + currentAttempt.wasRunning = Boolean.TRUE.equals(state.getRunning()); + if (DockerStatus.isContainerStopped(state)) { + currentAttempt.exitLog = lastLines(getLogs()); + } + } + + // Removes the containers of a failed attempt, and returns the failure if + // that fails, so that it can be reported along with the failure of the + // start, without changing its message. After an interrupt, it looks for + // them once more later. + private Optional removeContainersOf(Attempt failedAttempt, boolean isInterrupted) { + // Testcontainers removes a container whose start failed itself, but + // stopping it releases whatever Testcontainers may still hold for it, + // and removes it if an interrupt kept Testcontainers from doing so. + stop(); + + if (isInterrupted) { + removeContainersLater(failedAttempt.id); + } + + try { + containerRemover.accept(failedAttempt.id); + return Optional.empty(); + } catch (RuntimeException ex) { + return Optional.of(ex); + } + } + + // Removes the containers of an attempt after a delay, in the background. + // It knows the attempt only by its id, so that it does not touch the + // container, which may be started again by then. + private static void removeContainersLater(String attemptId) { + CompletableFuture.delayedExecutor(LATE_REMOVAL_DELAY.toNanos(), TimeUnit.NANOSECONDS) + .execute(() -> removeContainersWithLabel(attemptId)); + } + + private static void removeContainersWithLabel(String attemptId) { + var dockerClient = DockerClientFactory.instance().client(); + + dockerClient + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Map.of(START_LABEL, attemptId)) + .exec() + .forEach(container -> dockerClient + .removeContainerCmd(container.getId()) + .withForce(true) + .withRemoveVolumes(true) + .exec()); + } + + private static RuntimeException failed(RuntimeException failure, List removalFailures) { + removalFailures.forEach(failure::addSuppressed); + return failure; + } - @Override - public WaitStrategy withStartupTimeout(Duration startupTimeout) { - wait.withStartupTimeout(startupTimeout); - return this; + private IllegalStateException unreachable(Exception cause) { + return new IllegalStateException( + "failed to start the container, the database did not become reachable within " + + seconds(startupTimeout) + + "s in any of " + + MAX_START_ATTEMPTS + + " containers", + cause); + } + + // Restores the interrupt status, which the start cleared to clean up, + // and returns the exception for a start that an interrupt ended. The + // interrupt is its cause, which names the failure that the interrupt + // caused, if any. + private static IllegalStateException interrupted(@Nullable Throwable failure) { + Thread.currentThread().interrupt(); + + var interrupt = new InterruptedException(); + interrupt.initCause(failure); + return new IllegalStateException("failed to start the container: interrupted", interrupt); + } + + private static ContainerLaunchException exited(String exitLog, Exception cause) { + return new ContainerLaunchException( + "failed to start the container, it exited before the database became reachable: " + exitLog, cause); + } + + // Names the error of Docker, which Testcontainers wraps in exceptions of + // its own, as the innermost cause. Docker ends its errors with a line + // break, which the message leaves out. + private static ContainerLaunchException dockerFailed(Exception failure) { + Throwable dockerError = failure; + for (var cause = failure.getCause(); cause != null; cause = cause.getCause()) { + dockerError = cause; } + + return new ContainerLaunchException( + "failed to start the container: " + + Objects.toString(dockerError.getMessage()).stripTrailing(), + failure); + } + + // Describes a duration in seconds, as a whole number if it is one, and + // with as few decimals as needed otherwise, e.g. 1.5. + static String seconds(Duration duration) { + return BigDecimal.valueOf(duration.getSeconds()) + .add(BigDecimal.valueOf(duration.getNano(), 9)) + .stripTrailingZeros() + .toPlainString(); + } + + static String lastLines(String log) { + var lines = log.stripTrailing().lines().toList(); + return String.join("\n", lines.subList(Math.max(0, lines.size() - LOG_TAIL_LINES), lines.size())); } /** diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java new file mode 100644 index 0000000..a7ca08e --- /dev/null +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/AttemptTest.java @@ -0,0 +1,146 @@ +package io.thenativeweb.eventsourcingdb.testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerLaunchException; + +class AttemptTest { + private static final Duration ENOUGH_TIME = Duration.ofMinutes(1); + + // Docker publishes the port only for the second observation, and the + // database answers the ping only on the port of the third, as if Docker + // had published the port anew. + @Test + void readsThePortAnewForEveryPing() { + var attempt = new Attempt(ENOUGH_TIME); + var observations = new ArrayList<>(List.of( + new Attempt.Observation(false, null), + new Attempt.Observation(false, 1001), + new Attempt.Observation(false, 1002))); + var pingedPorts = new ArrayList(); + + attempt.waitUntilReachable(observations::removeFirst, port -> { + pingedPorts.add(port); + return port == 1002; + }); + + assertEquals(List.of(1001, 1002), pingedPorts); + assertTrue(attempt.hasWaitBegun); + assertFalse(attempt.hasRunOutOfTime); + } + + @Test + void endsTheWaitIfTheContainerExits() { + var attempt = new Attempt(ENOUGH_TIME); + + assertThrows( + ContainerLaunchException.class, + () -> attempt.waitUntilReachable(() -> new Attempt.Observation(true, 1001), port -> true)); + + assertFalse(attempt.hasRunOutOfTime); + } + + @Test + void endsTheWaitOnceTheStartupTimeoutRanOut() { + var attempt = new Attempt(Duration.ofMillis(300)); + var pingCount = new int[] {0}; + + var startedAt = System.nanoTime(); + assertThrows( + ContainerLaunchException.class, + () -> attempt.waitUntilReachable(() -> new Attempt.Observation(false, 1001), port -> { + pingCount[0]++; + return false; + })); + + assertTrue(attempt.hasRunOutOfTime); + assertTrue(Duration.ofNanos(System.nanoTime() - startedAt).compareTo(Duration.ofMillis(300)) >= 0); + assertNotEquals(0, pingCount[0]); + } + + // The clock starts once the attempt is created, so time that passes + // before the wait begins, e.g. while Docker creates the container, + // counts. + @Test + void countsTheTimeBeforeTheWaitBegins() { + var attempt = new Attempt(Duration.ZERO); + + assertThrows( + ContainerLaunchException.class, + () -> attempt.waitUntilReachable(() -> new Attempt.Observation(false, 1001), port -> true)); + + assertTrue(attempt.hasRunOutOfTime); + } + + // A failure to look at the container is one of Docker. It ends the wait + // at once, and does not count as running out of time. + @Test + void endsTheWaitAtOnceIfDockerFails() { + var attempt = new Attempt(ENOUGH_TIME); + var dockerFailure = new IllegalStateException("Docker failed"); + + var exception = assertThrows( + IllegalStateException.class, + () -> attempt.waitUntilReachable( + () -> { + throw dockerFailure; + }, + port -> true)); + + assertSame(dockerFailure, exception); + assertFalse(attempt.hasRunOutOfTime); + assertFalse(attempt.isUnreachable()); + } + + @Test + void isUnreachableIfTheWaitRanOutOfTime() { + var attempt = new Attempt(ENOUGH_TIME); + attempt.hasWaitBegun = true; + attempt.wasRunning = true; + attempt.hasRunOutOfTime = true; + + assertTrue(attempt.isUnreachable()); + } + + // Testcontainers gives up waiting for Docker to publish the port of the + // container after a fixed time of its own, before the wait begins. + @Test + void isUnreachableIfTheContainerWasRunningWhenTestcontainersGaveUpBeforeTheWait() { + var attempt = new Attempt(ENOUGH_TIME); + attempt.wasRunning = true; + + assertTrue(attempt.isUnreachable()); + } + + @Test + void isNotUnreachableIfTheContainerWasNotRunning() { + var attempt = new Attempt(ENOUGH_TIME); + + assertFalse(attempt.isUnreachable()); + } + + // The wait for the database ended before it ran out of time, e.g. + // because Docker failed while the container was running. + @Test + void isNotUnreachableIfTheWaitEndedForAnotherReason() { + var attempt = new Attempt(ENOUGH_TIME); + attempt.hasWaitBegun = true; + attempt.wasRunning = true; + + assertFalse(attempt.isUnreachable()); + } + + @Test + void hasAnIdOfItsOwn() { + assertNotEquals(new Attempt(ENOUGH_TIME).id, new Attempt(ENOUGH_TIME).id); + } +} diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index 5d1e8c5..0d46f2a 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -1,29 +1,59 @@ package io.thenativeweb.eventsourcingdb.testcontainers; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.sun.net.httpserver.HttpServer; import io.thenativeweb.eventsourcingdb.ClientOptions; import io.thenativeweb.eventsourcingdb.EventCandidate; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; import java.net.URI; +import java.net.http.HttpClient; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ContainerFetchException; import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.WaitStrategy; -import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.containers.output.WaitingConsumer; import tools.jackson.databind.PropertyNamingStrategies; import tools.jackson.databind.json.JsonMapper; class ContainerTest { + // The label that tells the containers of a test of reuse apart, so that + // they reuse only each other. + private static final String REUSE_TEST_LABEL = "io.eventsourcingdb.client-java.reuse-test"; + + // How long a start may take at most to give up once the caller was + // interrupted. + private static final Duration INTERRUPT_BOUND = Duration.ofSeconds(5); + + private static final String EXITED_PREFIX = + "failed to start the container, it exited before the database became reachable: "; + @Test void startsHandsOutAClientAndStops() { var container = new Container().withImageTag(ImageTag.get()); @@ -118,14 +148,6 @@ void handsOutAClientWithTheGivenOptions() { } } - @Test - void failsToStartWithAnImageThatDoesNotExist() { - var container = new Container().withImageTag("does-not-exist"); - - assertThrows(RuntimeException.class, container::start); - assertFalse(container.isRunning()); - } - @Test void reportsWhatItDoesNotHave() { var container = new Container(); @@ -155,144 +177,656 @@ void stopsWithoutFailingIfItIsNotRunning() { assertFalse(container.isRunning()); } - // Lists which of the given containers still exist in Docker, running or - // not. - private static List existing(List containerIds) { - return DockerClientFactory.instance() - .client() - .listContainersCmd() - .withShowAll(true) - .withIdFilter(containerIds) - .exec() - .stream() - .map(container -> container.getId()) - .toList(); + @Test + void waitsThirtySecondsForEachContainerByDefault() { + assertEquals(Duration.ofSeconds(30), new Container().startupTimeout); } - // A wait for pings that fails the given number of times while the - // container runs, as if its port were not reachable from the host, and - // then waits as the container does. It notes the containers it waited - // for, and the failures it threw. - private static final class UnreachableWait implements WaitStrategy { - private final WaitStrategy ping; - private final int failureCount; - final List containerIds = new ArrayList<>(); - final List failures = new ArrayList<>(); + // The database listens on another port than the one that Docker + // publishes, so it runs, but can not be reached, as if Docker did not + // make the published port reachable from the host. + @Test + void replacesAContainerInWhichTheDatabaseDoesNotBecomeReachable() { + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofSeconds(5)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + if (labels.size() == 1) { + listenOnAnotherPort(command); + } + }); + + container.start(); + try { + assertEquals(2, labels.size()); + assertNotEquals(labels.get(0), labels.get(1)); + assertEquals(List.of(), existing(labels.subList(0, 1))); + assertEquals(1, existing(labels.subList(1, 2)).size()); - UnreachableWait(WaitStrategy ping, int failureCount) { - this.ping = ping; - this.failureCount = failureCount; + container.getClient().ping(); + } finally { + container.stop(); } - @Override - public void waitUntilReady(WaitStrategyTarget target) { - containerIds.add(target.getContainerId()); - if (containerIds.size() > failureCount) { - ping.waitUntilReady(target); - return; + assertEquals(List.of(), existing(labels)); + } + + @Test + void givesUpAfterThreeContainersInWhichTheDatabaseDoesNotBecomeReachable() { + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofMillis(1500)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::listenOnAnotherPort); + + var startedAt = System.nanoTime(); + var exception = assertThrows(IllegalStateException.class, container::start); + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + + assertEquals(IllegalStateException.class, exception.getClass()); + assertEquals( + "failed to start the container, the database did not become reachable within 1.5s in any of 3" + + " containers", + exception.getMessage()); + assertEquals(3, labels.size()); + assertEquals(List.of(), existing(labels)); + assertFalse(container.isRunning()); + + // Each container gets the startup timeout that was set, rather than + // the default, but not more than that by far. + assertTrue(elapsed.compareTo(Duration.ofMillis(3 * 1500)) >= 0, elapsed::toString); + assertTrue(elapsed.compareTo(Duration.ofSeconds(30)) < 0, elapsed::toString); + } + + // A create that takes longer than the startup timeout, as the first one + // does here, leaves no time to wait for the database, as the time counts + // from creating the container on. The container that Docker creates after + // the time ran out is replaced and removed all the same. + @Test + void countsCreatingTheContainerTowardTheStartupTimeout() { + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofSeconds(5)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + if (labels.size() == 1) { + pause(Duration.ofMillis(5500)); } + }); + + container.start(); + try { + assertEquals(2, labels.size()); + assertEquals(List.of(), existing(labels.subList(0, 1))); + container.getClient().ping(); + } finally { + container.stop(); + } + } + + // Pulling the image takes longer than the startup timeout here, which + // must not count. + @Test + void doesNotCountPullingTheImageTowardTheStartupTimeout() { + var container = new Container().withStartupTimeout(Duration.ofSeconds(5)); + container.setImage(CompletableFuture.supplyAsync( + () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), + CompletableFuture.delayedExecutor(6, TimeUnit.SECONDS))); + var labels = labelsOf(container); - assertTrue(target.isRunning()); - var failure = new ContainerLaunchException("the database did not become reachable"); - failures.add(failure); - throw failure; + container.start(); + try { + assertEquals(1, labels.size()); + container.getClient().ping(); + } finally { + container.stop(); } + } - @Override - public WaitStrategy withStartupTimeout(Duration startupTimeout) { - return this; + // EventSourcingDB writes its error and its usage to different streams, + // so where the error ends up in the log varies. The test compares with + // the whole log, which it follows while the container runs. + @Test + void failsAtOnceIfTheContainerExits() throws TimeoutException { + var log = new ToStringConsumer(); + var logEnd = new WaitingConsumer(); + var container = new Container().withImageTag(ImageTag.get()).withLogConsumer(log.andThen(logEnd)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::passAnInvalidFlag); + + var exception = assertThrows(ContainerLaunchException.class, container::start); + + logEnd.waitUntilEnd(30, TimeUnit.SECONDS); + var lines = log.toUtf8String().stripTrailing().lines().toList(); + assertTrue(lines.size() > Container.LOG_TAIL_LINES, log::toUtf8String); + assertEquals( + EXITED_PREFIX + String.join("\n", lines.subList(lines.size() - Container.LOG_TAIL_LINES, lines.size())), + exception.getMessage()); + + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + assertFalse(container.isRunning()); + } + + // Docker rejects the name of the container when it is asked to create + // it, without asking a registry. + @Test + void failsAtOnceIfDockerFails() { + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> command.withName("not a valid name")); + + var exception = assertThrows(ContainerLaunchException.class, container::start); + + assertEquals( + "failed to start the container: " + + innermostCauseOf(exception).getMessage().stripTrailing(), + exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid container name"), exception::getMessage); + assertEquals(ContainerLaunchException.class, exception.getClass()); + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + assertFalse(container.isRunning()); + } + + @Test + void failsAtOnceIfTheImageDoesNotExist() { + var container = new Container().withImageTag("does-not-exist"); + var labels = labelsOf(container); + + var exception = assertThrows(ContainerLaunchException.class, container::start); + + assertEquals( + "failed to start the container: " + + innermostCauseOf(exception).getMessage().stripTrailing(), + exception.getMessage()); + assertInstanceOf(ContainerFetchException.class, exception.getCause()); + assertEquals(List.of(), labels); + assertFalse(container.isRunning()); + } + + // A container that carries the label of an attempt, but that + // Testcontainers does not know of, e.g. one that Docker created for a call + // that failed, is removed as well. + @Test + void removesTheContainersOfAFailedAttemptThatTestcontainersDoesNotKnowOf() { + var dockerClient = DockerClientFactory.instance().client(); + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> dockerClient + .createContainerCmd(command.getImage()) + .withLabels(Map.of(Container.START_LABEL, command.getLabels().get(Container.START_LABEL))) + .exec()); + container.withCreateContainerCmdModifier(ContainerTest::passAnInvalidFlag); + + try { + assertThrows(ContainerLaunchException.class, container::start); + + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } finally { + remove(labels); } } - // Docker Desktop sometimes does not make the port of a container - // reachable from the host. The container keeps running, but its wait - // fails, as it does here twice. Testcontainers stops a container whose - // wait fails before it reports the failure. @Test - void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() { + void reportsAFailedRemovalWithoutChangingTheMessage() { + var removalFailure = new IllegalStateException("removal failed"); var container = new Container().withImageTag(ImageTag.get()); - var wait = new AtomicReference(); - container.pingWait = port -> { - var unreachableWait = new UnreachableWait(new Container().pingWait.apply(port), 2); - wait.compareAndSet(null, unreachableWait); - return wait.get(); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::passAnInvalidFlag); + container.containerRemover = attemptId -> { + throw removalFailure; }; + var exception = assertThrows(ContainerLaunchException.class, container::start); + + assertTrue(exception.getMessage().startsWith(EXITED_PREFIX), exception::getMessage); + assertFalse(exception.getMessage().contains("removal failed"), exception::getMessage); + assertArrayEquals(new Throwable[] {removalFailure}, exception.getSuppressed()); + + // Testcontainers removed the container it started on its own. + assertEquals(List.of(), existing(labels)); + } + + // The database does not become reachable here, so the start is still + // waiting for it when the caller is interrupted. + @Test + void givesUpOnAnInterruptWhileItWaitsForTheDatabase() { + var caller = Thread.currentThread(); + var interruptedAt = new AtomicLong(); + var container = new Container().withImageTag(ImageTag.get()).withStartupTimeout(Duration.ofMinutes(1)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::listenOnAnotherPort); + container.withCreateContainerCmdModifier(command -> CompletableFuture.delayedExecutor(2, TimeUnit.SECONDS) + .execute(() -> { + interruptedAt.set(System.nanoTime()); + caller.interrupt(); + })); + + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); + } + var elapsed = Duration.ofNanos(System.nanoTime() - interruptedAt.get()); + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(INTERRUPT_BOUND) < 0, elapsed::toString); + + // The start removed the container before it returned, and did not + // replace it. + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } + + // The interrupt cuts the call to create the container short. Docker may + // still create the container afterwards, after the start looked for the + // containers to remove, which the test does itself here a second later. + // The start looks for them once more later, and removes it then. + @Test + void removesAContainerThatDockerCreatesAfterAnInterruptCutTheCreateShort() { + var dockerClient = DockerClientFactory.instance().client(); + var lateContainerId = new CompletableFuture(); + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + var label = command.getLabels().get(Container.START_LABEL); + CompletableFuture.delayedExecutor(1, TimeUnit.SECONDS) + .execute(() -> lateContainerId.complete(dockerClient + .createContainerCmd(command.getImage()) + .withLabels(Map.of(Container.START_LABEL, label)) + .exec() + .getId())); + Thread.currentThread().interrupt(); + }); + + try { + var startedAt = System.nanoTime(); + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); + } + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + + assertEquals(IllegalStateException.class, exception.getClass()); + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(INTERRUPT_BOUND) < 0, elapsed::toString); + + var lateContainer = lateContainerId.orTimeout(30, TimeUnit.SECONDS).join(); + assertEquals(List.of(lateContainer), existing(labels)); + + var deadline = System.nanoTime() + + Container.LATE_REMOVAL_DELAY.plusSeconds(10).toNanos(); + while (!existing(labels).isEmpty() && System.nanoTime() - deadline < 0) { + pause(Duration.ofMillis(100)); + } + + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } finally { + // The test creates the late container itself, so it waits for it + // before it cleans up, even if an assertion failed earlier. + lateContainerId + .orTimeout(30, TimeUnit.SECONDS) + .exceptionally(error -> "") + .join(); + remove(labels); + } + } + + // The attempt fails here without a call to Docker that would cut the + // interrupt short, so the interrupt status is still set once the attempt + // failed. The start removes the containers of the attempt all the same. + @Test + void removesTheContainersOfAnAttemptThatAnInterruptEnded() { + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while configuring the container"); + }); + + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); + } + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertTrue(wasInterrupted); + assertArrayEquals(new Throwable[0], exception.getSuppressed()); + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); + } + + // Testcontainers waits for the image here, which takes a minute, and + // reports the interrupt as the cause of its failure. + @Test + void givesUpOnAnInterruptWhileItPullsTheImage() { + var caller = Thread.currentThread(); + var container = new Container(); + container.setImage(CompletableFuture.supplyAsync( + () -> "thenativeweb/eventsourcingdb:" + ImageTag.get(), + CompletableFuture.delayedExecutor(1, TimeUnit.MINUTES))); + var labels = labelsOf(container); + CompletableFuture.delayedExecutor(1, TimeUnit.SECONDS).execute(caller::interrupt); + + var startedAt = System.nanoTime(); + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); + } + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(INTERRUPT_BOUND) < 0, elapsed::toString); + assertEquals(List.of(), labels); + } + + // Users configure the container with modifiers, which must run where + // Testcontainers runs them, on the thread that starts the container. + @Test + void runsModifiersOnTheThreadOfTheCaller() { + var modifierThreads = new CopyOnWriteArrayList(); + var container = new Container() + .withImageTag(ImageTag.get()) + .withCreateContainerCmdModifier(command -> modifierThreads.add(Thread.currentThread())); + container.start(); try { - var containerIds = wait.get().containerIds; - assertEquals(3, containerIds.size()); - assertEquals(List.of(containerIds.get(2)), existing(containerIds)); - container.getClient().ping(); + assertEquals(List.of(Thread.currentThread()), modifierThreads); } finally { container.stop(); } } + // The container resolved its image already, so the start needs no call to + // Docker that the interrupt could cut short before it asks Docker to + // create a container. @Test - void givesUpAfterThreeAttemptsAndSaysWhy() { + void givesUpAtOnceIfTheThreadIsInterruptedAlready() { var container = new Container().withImageTag(ImageTag.get()); - var wait = new UnreachableWait(new Container().pingWait.apply(3000), 3); - container.pingWait = port -> wait; + container.getDockerImageName(); + var labels = labelsOf(container); - var exception = assertThrows(IllegalStateException.class, container::start); + Thread.currentThread().interrupt(); + var startedAt = System.nanoTime(); + IllegalStateException exception; + boolean wasInterrupted; + try { + exception = assertThrows(IllegalStateException.class, container::start); + } finally { + wasInterrupted = Thread.interrupted(); + } + var elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + + assertEquals("failed to start the container: interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(wasInterrupted); + assertTrue(elapsed.compareTo(Duration.ofSeconds(1)) < 0, elapsed::toString); + assertEquals(List.of(), labels); + } + + // Testcontainers finds a container to reuse by the configuration of the + // container, which a label of each attempt would make differ every time. + @Test + void leavesAContainerThatTestcontainersReusesToTestcontainers() { + var reuseId = UUID.randomUUID().toString(); + var first = reusableContainer(reuseId); + var second = reusableContainer(reuseId); + var labels = labelsOf(second); + + first.start(); + try { + second.start(); + + assertEquals(first.getContainerId(), second.getContainerId()); + assertEquals(Collections.singletonList(null), labels); + assertFalse(DockerClientFactory.instance() + .client() + .inspectContainerCmd(first.getContainerId()) + .exec() + .getConfig() + .getLabels() + .containsKey(Container.START_LABEL)); + second.getClient().ping(); + } finally { + first.stop(); + second.stop(); + } + + assertEquals(List.of(), reusableContainers(reuseId)); + } + + // Testcontainers removes a reused container whose start fails on its own, + // and does not replace it. + @Test + void neitherReplacesNorRemovesAContainerThatTestcontainersReuses() { + var reuseId = UUID.randomUUID().toString(); + var container = reusableContainer(reuseId).withStartupTimeout(Duration.ofMillis(1500)); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(ContainerTest::listenOnAnotherPort); + + var exception = assertThrows(ContainerLaunchException.class, container::start); + assertEquals(ContainerLaunchException.class, exception.getClass()); assertEquals( - "failed to start container, the database did not become reachable in 3 attempts", - exception.getMessage()); - assertTrue(causesOf(exception).contains(wait.failures.getLast())); - assertEquals(3, wait.containerIds.size()); - assertEquals(List.of(), existing(wait.containerIds)); - assertFalse(container.isRunning()); + "failed to start the container: the database did not become reachable in time", exception.getMessage()); + assertEquals(Collections.singletonList(null), labels); + assertEquals(List.of(), reusableContainers(reuseId)); } - private static List causesOf(Throwable exception) { - var causes = new ArrayList(); - for (var cause = exception.getCause(); cause != null; cause = cause.getCause()) { - causes.add(cause); + // Asked for reuse, Testcontainers does not reuse containers unless the + // environment allows it, so the start labels, replaces, and removes them + // as usual. + @Test + void labelsAContainerThatAsksForReuseIfTheEnvironmentDoesNotAllowIt() { + var reuseId = UUID.randomUUID().toString(); + var container = reusableContainer(reuseId); + container.environmentSupportsReuse = () -> false; + var labels = labelsOf(container); + + container.start(); + try { + assertEquals(1, labels.size()); + assertNotNull(labels.getFirst()); + assertEquals(1, existing(labels).size()); + } finally { + container.stop(); } - return causes; + + assertEquals(List.of(), reusableContainers(reuseId)); } - // A startup timeout that is set on the container applies to its wait for - // pings. @Test - void passesAStartupTimeoutOnToTheWaitForPings() { - var timeouts = new ArrayList(); + void rejectsAStartupTimeoutThatIsNotPositive() { var container = new Container(); - var wait = container.new NotingWait(new WaitStrategy() { - @Override - public void waitUntilReady(WaitStrategyTarget target) {} - - @Override - public WaitStrategy withStartupTimeout(Duration startupTimeout) { - timeouts.add(startupTimeout); - return this; - } + + var zeroException = + assertThrows(IllegalArgumentException.class, () -> container.withStartupTimeout(Duration.ZERO)); + assertEquals("startup timeout must be positive", zeroException.getMessage()); + + var negativeException = assertThrows( + IllegalArgumentException.class, () -> container.withStartupTimeout(Duration.ofSeconds(-1))); + assertEquals("startup timeout must be positive", negativeException.getMessage()); + } + + // An error is no failure that the start handles, so it passes it on as it + // is. + @Test + void passesOnAnErrorThatEndsTheStart() { + var error = new AssertionError("broken"); + var container = new Container().withImageTag(ImageTag.get()); + var labels = labelsOf(container); + container.withCreateContainerCmdModifier(command -> { + throw error; }); - assertEquals(wait, wait.withStartupTimeout(Duration.ofSeconds(30))); - assertEquals(List.of(Duration.ofSeconds(30)), timeouts); + var exception = assertThrows(AssertionError.class, container::start); + + assertSame(error, exception); + assertEquals(1, labels.size()); + assertEquals(List.of(), existing(labels)); } @Test - void throwsAnyOtherFailureAtOnceAndRemovesTheContainer() { - var containerIds = new ArrayList(); + void takesOnly200OkAsAnAnswerToAPing() throws IOException { + assertTrue(answersPingWith(200)); + assertFalse(answersPingWith(204)); + assertFalse(answersPingWith(503)); + assertFalse(answersPingWithoutServer()); + } - // Without an API token, EventSourcingDB exits right away. - var container = new Container().withImageTag(ImageTag.get()).withApiToken(""); - container.starter = self -> { - try { - self.startOnce(); - } finally { - containerIds.add(self.getContainerId()); + @Test + void describesDurationsInSeconds() { + assertEquals("30", Container.seconds(Duration.ofSeconds(30))); + assertEquals("1.5", Container.seconds(Duration.ofMillis(1500))); + assertEquals("0.25", Container.seconds(Duration.ofMillis(250))); + assertEquals("0", Container.seconds(Duration.ZERO)); + } + + @Test + void takesTheLastLinesOfALog() { + var lines = new ArrayList(); + for (var number = 1; number <= Container.LOG_TAIL_LINES + 5; number++) { + lines.add("line " + number); + } + + assertEquals( + String.join("\n", lines.subList(5, lines.size())), + Container.lastLines(String.join("\n", lines) + "\n\n\n")); + assertEquals("line 1\nline 2", Container.lastLines("line 1\r\nline 2\n")); + assertEquals("", Container.lastLines("")); + } + + // Notes the label of each attempt as Testcontainers asks Docker to create + // its container. + private static List labelsOf(Container container) { + var labels = new CopyOnWriteArrayList(); + container.withCreateContainerCmdModifier( + command -> labels.add(command.getLabels().get(Container.START_LABEL))); + return labels; + } + + // A container that asks Testcontainers for reuse, and that only containers + // with the same id of reuse match. + private static Container reusableContainer(String reuseId) { + return new Container().withImageTag(ImageTag.get()).withReuse(true).withLabel(REUSE_TEST_LABEL, reuseId); + } + + private static List reusableContainers(String reuseId) { + return DockerClientFactory.instance() + .client() + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Map.of(REUSE_TEST_LABEL, reuseId)) + .exec() + .stream() + .map(container -> container.getId()) + .toList(); + } + + private static void listenOnAnotherPort(CreateContainerCmd command) { + var arguments = command.getCmd().clone(); + for (var index = 0; index < arguments.length - 1; index++) { + if (arguments[index].equals("--http-port")) { + arguments[index + 1] = String.valueOf(Integer.parseInt(arguments[index + 1]) + 1); } - }; + } + command.withCmd(arguments); + } - assertThrows(ContainerLaunchException.class, container::start); + private static void passAnInvalidFlag(CreateContainerCmd command) { + var arguments = new ArrayList<>(List.of(command.getCmd())); + arguments.add("--no-such-flag"); + command.withCmd(arguments); + } - assertEquals(1, containerIds.size()); - assertEquals(List.of(), existing(containerIds)); - assertFalse(container.isRunning()); + private static void pause(Duration duration) { + var deadline = System.nanoTime() + duration.toNanos(); + for (var remaining = duration.toNanos(); remaining > 0; remaining = deadline - System.nanoTime()) { + LockSupport.parkNanos(remaining); + } + } + + // Lists the ids of the containers that carry the label of one of the + // given attempts, running or not. It matches the exact id of each + // attempt, since the tests of other SDKs use the same label on the same + // Docker. + private static List existing(List labels) { + var dockerClient = DockerClientFactory.instance().client(); + + return labels.stream() + .flatMap(label -> dockerClient + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Map.of(Container.START_LABEL, label)) + .exec() + .stream()) + .map(container -> container.getId()) + .toList(); + } + + private static void remove(List labels) { + var dockerClient = DockerClientFactory.instance().client(); + for (var containerId : existing(labels)) { + dockerClient + .removeContainerCmd(containerId) + .withForce(true) + .withRemoveVolumes(true) + .exec(); + } + } + + private static Throwable innermostCauseOf(Throwable exception) { + var innermostCause = exception; + while (innermostCause.getCause() != null) { + innermostCause = innermostCause.getCause(); + } + return innermostCause; + } + + // Pings a server that answers with the given status code, on the same + // path as the database. + private static boolean answersPingWith(int statusCode) throws IOException { + var server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/api/v1/ping", exchange -> { + exchange.sendResponseHeaders(statusCode, -1); + exchange.close(); + }); + server.start(); + + try (var httpClient = HttpClient.newHttpClient()) { + return Container.answersPing( + httpClient, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/api/v1/ping")); + } finally { + server.stop(0); + } + } + + // Pings a port that nothing listens on, as a port that was free a moment + // ago is. + private static boolean answersPingWithoutServer() throws IOException { + int port; + try (var socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { + port = socket.getLocalPort(); + } + + try (var httpClient = HttpClient.newHttpClient()) { + return Container.answersPing(httpClient, URI.create("http://127.0.0.1:" + port + "/api/v1/ping")); + } } }