Skip to content

Commit b756527

Browse files
authored
Upgrade docker-compose image to latest version and perform dire… (#1847)
* Upgrade docker-compose image to latest version and perform direct image pull Together with using Compose file 2.1 syntax, this is a solution to network cleanup issue described in: * #1767 * #739 * testcontainers/moby-ryuk#2 * docker/compose#6636 Solution to general credential helper authenticated pull issues in: * docker/compose#5854 Tangentially should add support for v3 syntax (not yet tested) re #531
1 parent f1e9883 commit b756527

17 files changed

+250
-228
lines changed

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

Lines changed: 36 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22

33
import com.github.dockerjava.api.DockerClient;
44
import com.github.dockerjava.api.model.Container;
5-
import com.google.common.annotations.VisibleForTesting;
65
import com.google.common.base.Joiner;
76
import com.google.common.base.Splitter;
7+
import com.google.common.base.Strings;
88
import com.google.common.collect.Maps;
99
import com.google.common.util.concurrent.Uninterruptibles;
1010
import lombok.NonNull;
1111
import lombok.extern.slf4j.Slf4j;
12-
import org.apache.commons.io.FileUtils;
1312
import org.apache.commons.lang.StringUtils;
1413
import org.apache.commons.lang.SystemUtils;
1514
import org.junit.runner.Description;
@@ -19,25 +18,24 @@
1918
import org.testcontainers.containers.output.OutputFrame;
2019
import org.testcontainers.containers.output.Slf4jLogConsumer;
2120
import org.testcontainers.containers.startupcheck.IndefiniteWaitOneShotStartupCheckStrategy;
22-
import org.testcontainers.containers.wait.strategy.*;
21+
import org.testcontainers.containers.wait.strategy.Wait;
22+
import org.testcontainers.containers.wait.strategy.WaitAllStrategy;
23+
import org.testcontainers.containers.wait.strategy.WaitStrategy;
2324
import org.testcontainers.lifecycle.Startable;
2425
import org.testcontainers.utility.*;
25-
import org.yaml.snakeyaml.Yaml;
2626
import org.zeroturnaround.exec.InvalidExitValueException;
2727
import org.zeroturnaround.exec.ProcessExecutor;
2828
import org.zeroturnaround.exec.stream.slf4j.Slf4jStream;
2929

3030
import java.io.File;
31-
import java.io.FileInputStream;
32-
import java.io.IOException;
33-
import java.nio.file.*;
3431
import java.time.Duration;
3532
import java.util.AbstractMap.SimpleEntry;
3633
import java.util.*;
3734
import java.util.concurrent.ConcurrentHashMap;
3835
import java.util.concurrent.TimeUnit;
3936
import java.util.concurrent.atomic.AtomicInteger;
4037
import java.util.function.Consumer;
38+
import java.util.stream.Collectors;
4139
import java.util.stream.Stream;
4240

4341
import static com.google.common.base.Preconditions.checkArgument;
@@ -58,8 +56,7 @@ public class DockerComposeContainer<SELF extends DockerComposeContainer<SELF>> e
5856
*/
5957
private final String identifier;
6058
private final List<File> composeFiles;
61-
private final Set<String> spawnedContainerIds = new HashSet<>();
62-
private final Set<String> spawnedNetworkIds = new HashSet<>();
59+
private Set<ParsedDockerComposeFile> parsedComposeFiles;
6360
private final Map<String, Integer> scalingPreferences = new HashMap<>();
6461
private DockerClient dockerClient;
6562
private boolean localCompose;
@@ -107,10 +104,11 @@ public DockerComposeContainer(String identifier, File... composeFiles) {
107104
public DockerComposeContainer(String identifier, List<File> composeFiles) {
108105

109106
this.composeFiles = composeFiles;
107+
this.parsedComposeFiles = composeFiles.stream().map(ParsedDockerComposeFile::new).collect(Collectors.toSet());
110108

111109
// Use a unique identifier so that containers created for this compose environment can be identified
112110
this.identifier = identifier;
113-
project = randomProjectId();
111+
this.project = randomProjectId();
114112

115113
this.dockerClient = DockerClientFactory.instance().client();
116114
}
@@ -154,15 +152,25 @@ public void start() {
154152
log.warn("Exception while pulling images, using local images if available", e);
155153
}
156154
}
157-
applyScaling(); // scale before up, so that all scaled instances are available first for linking
158155
createServices();
159156
startAmbassadorContainers();
160157
waitUntilServiceStarted();
161158
}
162159
}
163160

164161
private void pullImages() {
165-
runWithCompose("pull");
162+
// Pull images using our docker client rather than compose itself,
163+
// (a) as a workaround for https://github.com/docker/compose/issues/5854, which prevents authenticated image pulls being possible when credential helpers are in use
164+
// (b) so that credential helper-based auth still works when compose is running from within a container
165+
parsedComposeFiles.stream()
166+
.flatMap(it -> it.getServiceImageNames().stream())
167+
.forEach(imageName -> {
168+
try {
169+
DockerClientFactory.instance().checkAndPullImage(dockerClient, imageName);
170+
} catch (Exception e) {
171+
log.warn("Failed to pull image '{}'. Exception message was {}", imageName, e.getMessage());
172+
}
173+
});
166174
}
167175

168176
public SELF withServices(@NonNull String... services) {
@@ -171,18 +179,23 @@ public SELF withServices(@NonNull String... services) {
171179
}
172180

173181
private void createServices() {
174-
// Run the docker-compose container, which starts up the services
175-
String command = "up -d";
182+
// Apply scaling
183+
final String servicesWithScalingSettings = Stream.concat(services.stream(), scalingPreferences.keySet().stream())
184+
.map(service -> "--scale " + service + "=" + scalingPreferences.getOrDefault(service, 1))
185+
.collect(joining(" "));
186+
187+
String flags = "-d";
176188

177189
if (build) {
178-
command += " --build";
190+
flags += " --build";
179191
}
180192

181-
if (!services.isEmpty()) {
182-
command += " " + String.join(" ", services);
193+
// Run the docker-compose container, which starts up the services
194+
if(Strings.isNullOrEmpty(servicesWithScalingSettings)) {
195+
runWithCompose("up " + flags);
196+
} else {
197+
runWithCompose("up " + flags + " " + servicesWithScalingSettings);
183198
}
184-
185-
runWithCompose(command);
186199
}
187200

188201
private void waitUntilServiceStarted() {
@@ -221,10 +234,6 @@ private void runWithCompose(String cmd) {
221234
checkNotNull(composeFiles);
222235
checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided");
223236

224-
for (File composeFile : composeFiles) {
225-
validate(composeFile);
226-
}
227-
228237
final DockerCompose dockerCompose;
229238
if (localCompose) {
230239
dockerCompose = new LocalDockerCompose(composeFiles, project);
@@ -238,72 +247,6 @@ private void runWithCompose(String cmd) {
238247
.invoke();
239248
}
240249

241-
@SuppressWarnings("unchecked")
242-
private static void validate(File composeFile) {
243-
Yaml yaml = new Yaml();
244-
try (FileInputStream fileInputStream = FileUtils.openInputStream(composeFile)) {
245-
Object template = yaml.load(fileInputStream);
246-
validate(template, composeFile.getAbsolutePath());
247-
} catch (IOException e) {
248-
log.warn("Failed to read YAML from {}", composeFile.getAbsolutePath(), e);
249-
}
250-
}
251-
252-
@VisibleForTesting
253-
static void validate(Object template, String identifier) {
254-
if (!(template instanceof Map)) {
255-
return;
256-
}
257-
258-
Map<String, ?> map = (Map<String, ?>) template;
259-
260-
final Map<String, ?> servicesMap;
261-
if (map.containsKey("version")) {
262-
if (!map.containsKey("services")) {
263-
log.debug("Compose file {} has an unknown format: 'version' is set but 'services' is not defined", identifier);
264-
return;
265-
}
266-
Object services = map.get("services");
267-
if (!(services instanceof Map)) {
268-
log.debug("Compose file {} has an unknown format: 'services' is not Map", identifier);
269-
return;
270-
}
271-
272-
servicesMap = (Map<String, ?>) services;
273-
} else {
274-
servicesMap = map;
275-
}
276-
277-
for (Map.Entry<String, ?> entry : servicesMap.entrySet()) {
278-
String serviceName = entry.getKey();
279-
Object serviceDefinition = entry.getValue();
280-
if (!(serviceDefinition instanceof Map)) {
281-
log.debug("Compose file {} has an unknown format: service '{}' is not Map", identifier, serviceName);
282-
break;
283-
}
284-
285-
if (((Map) serviceDefinition).containsKey("container_name")) {
286-
throw new IllegalStateException(String.format(
287-
"Compose file %s has 'container_name' property set for service '%s' but this property is not supported by Testcontainers, consider removing it",
288-
identifier,
289-
serviceName
290-
));
291-
}
292-
}
293-
}
294-
295-
private void applyScaling() {
296-
// Apply scaling
297-
if (!scalingPreferences.isEmpty()) {
298-
StringBuilder sb = new StringBuilder("scale");
299-
for (Map.Entry<String, Integer> scale : scalingPreferences.entrySet()) {
300-
sb.append(" ").append(scale.getKey()).append("=").append(scale.getValue());
301-
}
302-
303-
runWithCompose(sb.toString());
304-
}
305-
}
306-
307250
private void registerContainersForShutdown() {
308251
ResourceReaper.instance().registerFilterForCleanup(Arrays.asList(
309252
new SimpleEntry<>("label", "com.docker.compose.project=" + project)
@@ -333,29 +276,12 @@ public void stop() {
333276
ambassadorContainer.stop();
334277

335278
// Kill the services using docker-compose
336-
try {
337-
String cmd = "down -v";
338-
if (removeImages != null) {
339-
cmd += " --rmi " + removeImages.dockerRemoveImagesType();
340-
}
341-
runWithCompose(cmd);
342-
343-
// If we reach here then docker-compose down has cleared networks and containers;
344-
// we can unregister from ResourceReaper
345-
spawnedContainerIds.forEach(ResourceReaper.instance()::unregisterContainer);
346-
spawnedNetworkIds.forEach(ResourceReaper.instance()::unregisterNetwork);
347-
} catch (Exception e) {
348-
// docker-compose down failed; use ResourceReaper to ensure cleanup
349-
350-
// kill the spawned service containers
351-
spawnedContainerIds.forEach(ResourceReaper.instance()::stopAndRemoveContainer);
352-
353-
// remove the networks after removing the containers
354-
spawnedNetworkIds.forEach(ResourceReaper.instance()::removeNetworkById);
279+
String cmd = "down -v";
280+
if (removeImages != null) {
281+
cmd += " --rmi " + removeImages.dockerRemoveImagesType();
355282
}
283+
runWithCompose(cmd);
356284

357-
spawnedContainerIds.clear();
358-
spawnedNetworkIds.clear();
359285
} finally {
360286
project = randomProjectId();
361287
}
@@ -597,9 +523,6 @@ interface DockerCompose {
597523
class ContainerisedDockerCompose extends GenericContainer<ContainerisedDockerCompose> implements DockerCompose {
598524

599525
private static final String DOCKER_SOCKET_PATH = "/var/run/docker.sock";
600-
private static final String DOCKER_CONFIG_FILE = "/root/.docker/config.json";
601-
private static final String DOCKER_CONFIG_ENV = "DOCKER_CONFIG_FILE";
602-
private static final String DOCKER_CONFIG_PROPERTY = "dockerConfigFile";
603526
public static final char UNIX_PATH_SEPERATOR = ':';
604527

605528
public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
@@ -630,27 +553,6 @@ public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
630553
addEnv("DOCKER_HOST", "unix:///docker.sock");
631554
setStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy());
632555
setWorkingDirectory(containerPwd);
633-
634-
String dockerConfigPath = determineDockerConfigPath();
635-
if (dockerConfigPath != null && !dockerConfigPath.isEmpty()) {
636-
addFileSystemBind(dockerConfigPath, DOCKER_CONFIG_FILE, READ_ONLY);
637-
}
638-
}
639-
640-
private String determineDockerConfigPath() {
641-
String dockerConfigEnv = System.getenv(DOCKER_CONFIG_ENV);
642-
String dockerConfigProperty = System.getProperty(DOCKER_CONFIG_PROPERTY);
643-
Path dockerConfig = Paths.get(System.getProperty("user.home"), ".docker", "config.json");
644-
645-
if (dockerConfigEnv != null && !dockerConfigEnv.trim().isEmpty() && Files.exists(Paths.get(dockerConfigEnv))) {
646-
return dockerConfigEnv;
647-
} else if (dockerConfigProperty != null && !dockerConfigProperty.trim().isEmpty() && Files.exists(Paths.get(dockerConfigProperty))) {
648-
return dockerConfigProperty;
649-
} else if (Files.exists(dockerConfig)) {
650-
return dockerConfig.toString();
651-
} else {
652-
return null;
653-
}
654556
}
655557

656558
private String getDockerSocketHostPath() {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package org.testcontainers.containers;
2+
3+
import com.google.common.annotations.VisibleForTesting;
4+
import lombok.EqualsAndHashCode;
5+
import lombok.Getter;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.apache.commons.io.FileUtils;
8+
import org.yaml.snakeyaml.Yaml;
9+
10+
import java.io.File;
11+
import java.io.FileInputStream;
12+
import java.util.HashSet;
13+
import java.util.Map;
14+
import java.util.Set;
15+
16+
/**
17+
* Representation of a docker-compose file, with partial parsing for validation and extraction of a minimal set of
18+
* data.
19+
*/
20+
@Slf4j
21+
@EqualsAndHashCode
22+
class ParsedDockerComposeFile {
23+
24+
private final Map<String, Object> composeFileContent;
25+
private final String composeFileName;
26+
27+
@Getter
28+
private Set<String> serviceImageNames = new HashSet<>();
29+
30+
ParsedDockerComposeFile(File composeFile) {
31+
Yaml yaml = new Yaml();
32+
try (FileInputStream fileInputStream = FileUtils.openInputStream(composeFile)) {
33+
composeFileContent = yaml.load(fileInputStream);
34+
} catch (Exception e) {
35+
throw new IllegalArgumentException("Unable to parse YAML file from " + composeFile.getAbsolutePath(), e);
36+
}
37+
this.composeFileName = composeFile.getAbsolutePath();
38+
39+
parseAndValidate();
40+
}
41+
42+
@VisibleForTesting
43+
ParsedDockerComposeFile(Map<String, Object> testContent) {
44+
this.composeFileContent = testContent;
45+
this.composeFileName = "";
46+
47+
parseAndValidate();
48+
}
49+
50+
private void parseAndValidate() {
51+
final Map<String, ?> servicesMap;
52+
if (composeFileContent.containsKey("version")) {
53+
if ("2.0".equals(composeFileContent.get("version"))) {
54+
log.warn("Testcontainers may not be able to clean up networks spawned using Docker Compose v2.0 files. " +
55+
"Please see https://github.com/testcontainers/moby-ryuk/issues/2, and specify 'version: \"2.1\"' or " +
56+
"higher in {}", composeFileName);
57+
}
58+
59+
final Object servicesElement = composeFileContent.get("services");
60+
if (servicesElement == null) {
61+
log.debug("Compose file {} has an unknown format: 'version' is set but 'services' is not defined", composeFileName);
62+
return;
63+
}
64+
if (!(servicesElement instanceof Map)) {
65+
log.debug("Compose file {} has an unknown format: 'services' is not Map", composeFileName);
66+
return;
67+
}
68+
69+
servicesMap = (Map<String, ?>) servicesElement;
70+
} else {
71+
servicesMap = composeFileContent;
72+
}
73+
74+
for (Map.Entry<String, ?> entry : servicesMap.entrySet()) {
75+
String serviceName = entry.getKey();
76+
Object serviceDefinition = entry.getValue();
77+
if (!(serviceDefinition instanceof Map)) {
78+
log.debug("Compose file {} has an unknown format: service '{}' is not Map", composeFileName, serviceName);
79+
break;
80+
}
81+
82+
final Map serviceDefinitionMap = (Map) serviceDefinition;
83+
if (serviceDefinitionMap.containsKey("container_name")) {
84+
throw new IllegalStateException(String.format(
85+
"Compose file %s has 'container_name' property set for service '%s' but this property is not supported by Testcontainers, consider removing it",
86+
composeFileName,
87+
serviceName
88+
));
89+
}
90+
if (serviceDefinitionMap.containsKey("image") && serviceDefinitionMap.get("image") instanceof String) {
91+
serviceImageNames.add((String) serviceDefinitionMap.get("image"));
92+
}
93+
}
94+
}
95+
}

core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public String getVncRecordedContainerImage() {
5151
}
5252

5353
public String getDockerComposeContainerImage() {
54-
return (String) properties.getOrDefault("compose.container.image", "docker/compose:1.8.0");
54+
return (String) properties.getOrDefault("compose.container.image", "docker/compose:1.24.1");
5555
}
5656

5757
public String getTinyImage() {

0 commit comments

Comments
 (0)