Skip to content

Commit f1ce2b1

Browse files
authored
Wait robustness (#504)
Refactor port awaits for improved robustness: * We now allow a list of multiple startup liveness check ports to be defined for a container. This helps, e.g. for the browser containers, and lets us wait for both Selenium and VNC ports to be listening. I think this will help eliminate random flapping tests in this area and should fix #466 * For exposed/bound ports, we now check that the port is accepting connections both (1) from within the container, and (2) from the testcontainers host. Previously, the internal check was done for Docker on Mac/Win and the external check for Linux. Now both are used for all environments, which simplifies the logic and gives a solid guarantee that the port truly is listening. (For reference, the internal check was done due to issues with the Docker networking stack opening a listening socket before the containerized service itself was listening). * Also, we now have a WaitAllStrategy that lets more than one wait strategy be used. For browser containers, we now wait for (a) a log message, and (b) the listening ports to be available. * Broken out some aspects of the wait strategies/port detection into separate classes and used this to help improve test coverage. * Refactored/tidied some code in network link configuration that was redundant, using Docker API filters instead of streamed filtering of containers
1 parent 6620d23 commit f1ce2b1

File tree

16 files changed

+434
-110
lines changed

16 files changed

+434
-110
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ All notable changes to this project will be documented in this file.
99
- Allowing `addExposedPort` to be used after ports have been specified with `withExposedPorts` (#453)
1010
- Stopping creation of temporary directory prior to creating temporary file (#443)
1111
- Ensure that temp files are created in a temp directory (#423)
12+
- Added `WaitAllStrategy` as a mechanism for composing multiple startup `WaitStrategy` objects together
13+
- Changed `BrowserWebDriverContainer` to use improved wait strategies, to eliminate race conditions when starting VNC recording containers. This should lead to far fewer 'error' messages logged when starting up selenium containers, and less exposure to race related bugs (fixes #466).
1214

1315
### Changed
1416
- Make Network instances reusable (i.e. work with `@ClassRule`) ([\#469](https://github.com/testcontainers/testcontainers-java/issues/469))
1517
- Added support for explicitly setting file mode when copying file into container ([\#446](https://github.com/testcontainers/testcontainers-java/issues/446), [\#467](https://github.com/testcontainers/testcontainers-java/issues/467))
1618
- Use Visible Assertions 2.1.0 for pre-flight test output (eliminating Jansi/JNR-POSIX dependencies for lower likelihood of conflict. JNA is now used internally by Visible Assertions instead).
1719
- Mark all links functionality as deprecated. This is pending removal in a later release. Please see [\#465](https://github.com/testcontainers/testcontainers-java/issues/465). {@link Network} features should be used instead.
1820
- Added support for copying files to/from running containers ([\#378](https://github.com/testcontainers/testcontainers-java/issues/378))
21+
- Add `getLivenessCheckPorts` as an eventual replacement for `getLivenessCheckPort`; this allows multiple ports to be included in post-startup wait strategies.
22+
- Refactor wait strategy port checking and improve test coverage.
1923
- Added support for customising the recording file name ([\#500](https://github.com/testcontainers/testcontainers-java/issues/500))
2024

2125
## [1.4.3] - 2017-10-14

core/src/main/java/org/testcontainers/containers/GenericContainer.java

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
import com.google.common.base.Preconditions;
1010
import com.google.common.base.Strings;
1111
import lombok.*;
12-
import org.apache.commons.compress.utils.IOUtils;
1312
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
13+
import org.apache.commons.compress.utils.IOUtils;
14+
import org.jetbrains.annotations.NotNull;
1415
import org.jetbrains.annotations.Nullable;
1516
import org.junit.runner.Description;
1617
import org.rnorth.ducttape.ratelimits.RateLimiter;
@@ -44,6 +45,7 @@
4445
import java.util.concurrent.atomic.AtomicInteger;
4546
import java.util.function.Consumer;
4647
import java.util.stream.Collectors;
48+
import java.util.stream.Stream;
4749

4850
import static com.google.common.collect.Lists.newArrayList;
4951
import static org.testcontainers.containers.output.OutputFrame.OutputType.STDERR;
@@ -96,7 +98,6 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>
9698
@NonNull
9799
private List<Bind> binds = new ArrayList<>();
98100

99-
@NonNull
100101
private boolean privilegedMode;
101102

102103
@NonNull
@@ -326,8 +327,11 @@ protected void containerIsStarted(InspectContainerResponse containerInfo) {
326327

327328
/**
328329
* @return the port on which to check if the container is ready
330+
* @deprecated see {@link GenericContainer#getLivenessCheckPorts()} for replacement
329331
*/
332+
@Deprecated
330333
protected Integer getLivenessCheckPort() {
334+
// legacy implementation for backwards compatibility
331335
if (exposedPorts.size() > 0) {
332336
return getMappedPort(exposedPorts.get(0));
333337
} else if (portBindings.size() > 0) {
@@ -337,6 +341,39 @@ protected Integer getLivenessCheckPort() {
337341
}
338342
}
339343

344+
/**
345+
* @return the ports on which to check if the container is ready
346+
*/
347+
@NotNull
348+
@NonNull
349+
protected Set<Integer> getLivenessCheckPorts() {
350+
final Set<Integer> result = new HashSet<>();
351+
result.addAll(getExposedPortNumbers());
352+
result.addAll(getBoundPortNumbers());
353+
354+
// for backwards compatibility
355+
if (this.getLivenessCheckPort() != null) {
356+
result.add(this.getLivenessCheckPort());
357+
}
358+
359+
return result;
360+
}
361+
362+
private List<Integer> getExposedPortNumbers() {
363+
return exposedPorts.stream()
364+
.map(this::getMappedPort)
365+
.collect(Collectors.toList());
366+
}
367+
368+
private List<Integer> getBoundPortNumbers() {
369+
return portBindings.stream()
370+
.map(PortBinding::parse)
371+
.map(PortBinding::getBinding)
372+
.map(Ports.Binding::getHostPortSpec)
373+
.map(Integer::valueOf)
374+
.collect(Collectors.toList());
375+
}
376+
340377
private void applyConfiguration(CreateContainerCmd createCommand) {
341378

342379
// Set up exposed ports (where there are no host port bindings defined)
@@ -376,31 +413,16 @@ private void applyConfiguration(CreateContainerCmd createCommand) {
376413
String alias = linkEntries.getKey();
377414
LinkableContainer linkableContainer = linkEntries.getValue();
378415

379-
Set<Link> links = dockerClient.listContainersCmd().exec().stream()
380-
.filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName()))
381-
.map(container -> new Link(container.getNames()[0], alias))
382-
.collect(Collectors.toSet());
416+
Set<Link> links = findLinksFromThisContainer(alias, linkableContainer);
383417
allLinks.addAll(links);
384418

385-
boolean linkableContainerIsRunning = dockerClient.listContainersCmd().exec().stream()
386-
.filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName()))
387-
.map(com.github.dockerjava.api.model.Container::getId)
388-
.map(id -> dockerClient.inspectContainerCmd(id).exec())
389-
.anyMatch(linkableContainerInspectResponse -> linkableContainerInspectResponse.getState().getRunning());
390-
391-
if (!linkableContainerIsRunning) {
419+
if (allLinks.size() == 0) {
392420
throw new ContainerLaunchException("Aborting attempt to link to container " +
393421
linkableContainer.getContainerName() +
394422
" as it is not running");
395423
}
396424

397-
Set<String> linkedContainerNetworks = dockerClient.listContainersCmd().exec().stream()
398-
.filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName()))
399-
.filter(container -> container.getNetworkSettings() != null &&
400-
container.getNetworkSettings().getNetworks() != null)
401-
.flatMap(container -> container.getNetworkSettings().getNetworks().keySet().stream())
402-
.distinct()
403-
.collect(Collectors.toSet());
425+
Set<String> linkedContainerNetworks = findAllNetworksForLinkedContainers(linkableContainer);
404426
allLinkedContainerNetworks.addAll(linkedContainerNetworks);
405427
}
406428

@@ -443,6 +465,26 @@ private void applyConfiguration(CreateContainerCmd createCommand) {
443465
createCommand.withLabels(Collections.singletonMap("org.testcontainers", "true"));
444466
}
445467

468+
private Set<Link> findLinksFromThisContainer(String alias, LinkableContainer linkableContainer) {
469+
return dockerClient.listContainersCmd()
470+
.withStatusFilter("running")
471+
.exec().stream()
472+
.flatMap(container -> Stream.of(container.getNames()))
473+
.filter(name -> name.endsWith(linkableContainer.getContainerName()))
474+
.map(name -> new Link(name, alias))
475+
.collect(Collectors.toSet());
476+
}
477+
478+
private Set<String> findAllNetworksForLinkedContainers(LinkableContainer linkableContainer) {
479+
return dockerClient.listContainersCmd().exec().stream()
480+
.filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName()))
481+
.filter(container -> container.getNetworkSettings() != null &&
482+
container.getNetworkSettings().getNetworks() != null)
483+
.flatMap(container -> container.getNetworkSettings().getNetworks().keySet().stream())
484+
.distinct()
485+
.collect(Collectors.toSet());
486+
}
487+
446488
/**
447489
* {@inheritDoc}
448490
*/
@@ -1015,11 +1057,20 @@ protected Logger logger() {
10151057

10161058
/**
10171059
* @return the port on which to check if the container is ready
1060+
* @deprecated see {@link AbstractWaitStrategy#getLivenessCheckPorts()}
10181061
*/
1062+
@Deprecated
10191063
protected Integer getLivenessCheckPort() {
10201064
return container.getLivenessCheckPort();
10211065
}
10221066

1067+
/**
1068+
* @return the ports on which to check if the container is ready
1069+
*/
1070+
protected Set<Integer> getLivenessCheckPorts() {
1071+
return container.getLivenessCheckPorts();
1072+
}
1073+
10231074
/**
10241075
* @return the rate limiter to use
10251076
*/

core/src/main/java/org/testcontainers/containers/VncRecordingSidekickContainer.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.testcontainers.containers;
22

33
import com.github.dockerjava.api.command.InspectContainerResponse;
4+
import org.jetbrains.annotations.NotNull;
45
import org.testcontainers.containers.traits.LinkableContainer;
56
import org.testcontainers.containers.traits.VncService;
67
import org.testcontainers.utility.TestcontainersConfiguration;
@@ -10,6 +11,9 @@
1011
import java.nio.file.Files;
1112
import java.nio.file.Path;
1213
import java.nio.file.StandardCopyOption;
14+
import java.util.Set;
15+
16+
import static java.util.Collections.emptySet;
1317

1418
/**
1519
* 'Sidekick container' with the sole purpose of recording the VNC screen output from another container.
@@ -46,10 +50,11 @@ protected void containerIsStarting(InspectContainerResponse containerInfo) {
4650
// do nothing
4751
}
4852

53+
@NotNull
4954
@Override
50-
protected Integer getLivenessCheckPort() {
55+
protected Set<Integer> getLivenessCheckPorts() {
5156
// no liveness check needed
52-
return null;
57+
return emptySet();
5358
}
5459

5560
@Override
Lines changed: 21 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
package org.testcontainers.containers.wait;
22

33
import lombok.extern.slf4j.Slf4j;
4-
import org.apache.commons.lang.SystemUtils;
54
import org.rnorth.ducttape.TimeoutException;
65
import org.rnorth.ducttape.unreliables.Unreliables;
7-
import org.testcontainers.DockerClientFactory;
86
import org.testcontainers.containers.ContainerLaunchException;
97
import org.testcontainers.containers.GenericContainer;
10-
import org.testcontainers.dockerclient.DockerMachineClientProviderStrategy;
11-
import org.testcontainers.dockerclient.WindowsClientProviderStrategy;
8+
import org.testcontainers.containers.wait.internal.ExternalPortListeningCheck;
9+
import org.testcontainers.containers.wait.internal.InternalCommandPortListeningCheck;
1210

13-
import java.net.Socket;
1411
import java.util.List;
12+
import java.util.Set;
1513
import java.util.concurrent.Callable;
1614
import java.util.concurrent.TimeUnit;
15+
import java.util.stream.Collectors;
1716

1817
/**
1918
* Waits until a socket connection can be established on a port exposed or mapped by the container.
@@ -23,87 +22,40 @@
2322
@Slf4j
2423
public class HostPortWaitStrategy extends GenericContainer.AbstractWaitStrategy {
2524

26-
private static final String SUCCESS_MARKER = "TESTCONTAINERS_SUCCESS";
2725
@Override
2826
protected void waitUntilReady() {
29-
final Integer port = getLivenessCheckPort();
30-
if (null == port) {
31-
log.debug("Liveness check port of {} is empty. Not waiting.", container.getContainerName());
27+
final Set<Integer> externalLivenessCheckPorts = getLivenessCheckPorts();
28+
if (externalLivenessCheckPorts.isEmpty()) {
29+
log.debug("Liveness check ports of {} is empty. Not waiting.", container.getContainerName());
3230
return;
3331
}
3432

35-
Callable<Boolean> checkStrategy;
33+
@SuppressWarnings("unchecked")
34+
List<Integer> exposedPorts = container.getExposedPorts();
3635

37-
if (shouldCheckWithCommand()) {
38-
List<Integer> exposedPorts = container.getExposedPorts();
36+
final Set<Integer> internalPorts = getInternalPorts(externalLivenessCheckPorts, exposedPorts);
3937

40-
Integer exposedPort = exposedPorts.stream()
41-
.filter(it -> port.equals(container.getMappedPort(it)))
42-
.findFirst()
43-
.orElse(null);
38+
Callable<Boolean> internalCheck = new InternalCommandPortListeningCheck(container, internalPorts);
4439

45-
if (null == exposedPort) {
46-
log.warn("Liveness check port of {} is set to {}, but it's not listed in exposed ports.",
47-
container.getContainerName(), port);
48-
return;
49-
}
50-
51-
String[][] commands = {
52-
{ "/bin/sh", "-c", "nc -vz -w 1 localhost " + exposedPort + " && echo " + SUCCESS_MARKER },
53-
{ "/bin/bash", "-c", "</dev/tcp/localhost/" + exposedPort + " && echo " + SUCCESS_MARKER }
54-
};
55-
56-
checkStrategy = () -> {
57-
for (String[] command : commands) {
58-
try {
59-
if (container.execInContainer(command).getStdout().contains(SUCCESS_MARKER)) {
60-
return true;
61-
}
62-
} catch (InterruptedException e) {
63-
throw new RuntimeException(e);
64-
} catch (Exception e) {
65-
continue;
66-
}
67-
}
68-
69-
return false;
70-
};
71-
} else {
72-
checkStrategy = () -> {
73-
new Socket(container.getContainerIpAddress(), port).close();
74-
return true;
75-
};
76-
}
40+
Callable<Boolean> externalCheck = new ExternalPortListeningCheck(container, externalLivenessCheckPorts);
7741

7842
try {
7943
Unreliables.retryUntilTrue((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, () -> {
80-
return getRateLimiter().getWhenReady(() -> {
81-
try {
82-
return checkStrategy.call();
83-
} catch (Exception e) {
84-
throw new RuntimeException(e);
85-
}
86-
});
44+
return getRateLimiter().getWhenReady(() -> internalCheck.call() && externalCheck.call());
8745
});
8846

8947
} catch (TimeoutException e) {
9048
throw new ContainerLaunchException("Timed out waiting for container port to open (" +
91-
container.getContainerIpAddress() + ":" + port + " should be listening)");
49+
container.getContainerIpAddress() +
50+
" ports: " +
51+
externalLivenessCheckPorts +
52+
" should be listening)");
9253
}
9354
}
9455

95-
private boolean shouldCheckWithCommand() {
96-
// Special case for Docker for Mac, see #160
97-
if (! DockerClientFactory.instance().isUsing(DockerMachineClientProviderStrategy.class) &&
98-
SystemUtils.IS_OS_MAC_OSX) {
99-
return true;
100-
}
101-
102-
// Special case for Docker for Windows, see #160
103-
if (DockerClientFactory.instance().isUsing(WindowsClientProviderStrategy.class)) {
104-
return true;
105-
}
106-
107-
return false;
56+
private Set<Integer> getInternalPorts(Set<Integer> externalLivenessCheckPorts, List<Integer> exposedPorts) {
57+
return exposedPorts.stream()
58+
.filter(it -> externalLivenessCheckPorts.contains(container.getMappedPort(it)))
59+
.collect(Collectors.toSet());
10860
}
10961
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package org.testcontainers.containers.wait;
2+
3+
import org.rnorth.ducttape.timeouts.Timeouts;
4+
import org.testcontainers.containers.GenericContainer;
5+
6+
import java.time.Duration;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.concurrent.TimeUnit;
10+
11+
/**
12+
* Wait strategy that waits for a number of other strategies to pass in series.
13+
*/
14+
public class WaitAllStrategy implements WaitStrategy {
15+
16+
private final List<WaitStrategy> strategies = new ArrayList<>();
17+
private Duration timeout = Duration.ofSeconds(30);
18+
19+
@Override
20+
public void waitUntilReady(GenericContainer container) {
21+
Timeouts.doWithTimeout((int) timeout.toMillis(), TimeUnit.MILLISECONDS, () -> {
22+
for (WaitStrategy strategy : strategies) {
23+
strategy.waitUntilReady(container);
24+
}
25+
});
26+
}
27+
28+
public WaitAllStrategy withStrategy(WaitStrategy strategy) {
29+
this.strategies.add(strategy);
30+
return this;
31+
}
32+
33+
@Override
34+
public WaitStrategy withStartupTimeout(Duration startupTimeout) {
35+
this.timeout = startupTimeout;
36+
return this;
37+
}
38+
}

0 commit comments

Comments
 (0)