Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for compose v2 with ComposeContainer #5608

Merged
merged 48 commits into from
Jun 21, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
8573b7e
Add support for compose v2
eddumelendez Jul 24, 2022
9f3983b
Add configuration property to enable compose v2 compatibility
eddumelendez Jul 28, 2022
2f4c569
Add docs
eddumelendez Jul 28, 2022
cb139d5
Fix tests
eddumelendez Jul 28, 2022
69dbf78
Fix assumption
eddumelendez Jul 28, 2022
7f9e995
Update docs/modules/docker_compose.md
eddumelendez Jul 29, 2022
48b15a9
Autodetect compose v2
eddumelendez Jul 29, 2022
2cdfb5c
Add testcontainers prefix to config property
eddumelendez Jul 29, 2022
deb5f2b
Merge branch 'master' into composev2
eddumelendez Jul 29, 2022
afbc537
Fix test
eddumelendez Jul 29, 2022
01f28f8
Fix test
eddumelendez Jul 30, 2022
e8e07c5
Fix comments
eddumelendez Aug 2, 2022
6b670d4
Set composev2 enabled by default and add compatibility mode
eddumelendez Aug 13, 2022
c8b49b4
Merge branch 'master' into composev2
eddumelendez Aug 13, 2022
1fd85c5
Fix test
eddumelendez Aug 13, 2022
d65db21
Update missing test
eddumelendez Aug 13, 2022
1b4c884
Add ComposeContainer
eddumelendez Aug 16, 2022
f7d875f
Remove test
eddumelendez Aug 16, 2022
f1436d6
Remove configuration doc
eddumelendez Aug 16, 2022
e67ebc6
Merge branch 'master' into composev2
eddumelendez Aug 31, 2022
a252f29
Changes
eddumelendez Sep 1, 2022
b6c9ddd
Refactor DockerComposeContainer
eddumelendez Sep 26, 2022
3d2c056
Merge branch 'master' into composev2
eddumelendez Sep 26, 2022
153b9ef
Merge branch 'main' into composev2
eddumelendez Sep 30, 2022
8e37af6
Rename and introduce ComposeVersion enum
eddumelendez Sep 30, 2022
2d07a92
Update docs/modules/docker_compose.md
eddumelendez Sep 30, 2022
dc785f1
Updates
eddumelendez Sep 30, 2022
40e08db
Update ComposeContainer
eddumelendez Sep 30, 2022
9619dee
Fix compose v2 start commmand
eddumelendez Oct 3, 2022
b6f0829
Add options in specific position for compose v2
eddumelendez Oct 3, 2022
04e04cb
Merge branch 'main' into composev2
eddumelendez Oct 3, 2022
10919cc
Fix test
eddumelendez Oct 3, 2022
2214f8a
Merge branch 'main' into composev2
eddumelendez Apr 14, 2023
ca78119
Copy compose file to work with remote docker
eddumelendez Apr 14, 2023
7bbf135
Update docker images to run in arm
eddumelendez Apr 18, 2023
c829746
Use different redis versions
eddumelendez Apr 19, 2023
196d180
Merge branch 'main' into composev2
eddumelendez Apr 29, 2023
337e0c7
Drop link in favor of networks
eddumelendez May 2, 2023
601148e
Merge branch 'main' into composev2
eddumelendez May 10, 2023
0840644
Add test for identifier in uppercase
eddumelendez May 10, 2023
6a39b89
Revert "Drop link in favor of networks"
eddumelendez May 10, 2023
c2dcc88
Merge branch 'main' into composev2
eddumelendez May 16, 2023
39b37d7
Fix test
eddumelendez May 17, 2023
a078382
Update core/src/main/java/org/testcontainers/containers/ComposeContai…
eddumelendez Jun 20, 2023
4c7ca00
Merge branch 'main' into composev2
eddumelendez Jun 20, 2023
9317f81
Add note
eddumelendez Jun 20, 2023
3e21972
Update docker version
eddumelendez Jun 20, 2023
7e2f2da
Update labeler.yml
eddumelendez Jun 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.google.common.collect.Sets;
import com.google.common.util.concurrent.Uninterruptibles;
import lombok.NonNull;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils;
Expand All @@ -39,11 +40,13 @@
import org.testcontainers.utility.MountableFile;
import org.testcontainers.utility.PathUtils;
import org.testcontainers.utility.ResourceReaper;
import org.testcontainers.utility.TestcontainersConfiguration;
import org.zeroturnaround.exec.InvalidExitValueException;
import org.zeroturnaround.exec.ProcessExecutor;
import org.zeroturnaround.exec.stream.slf4j.Slf4jStream;

import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
Expand Down Expand Up @@ -113,6 +116,18 @@ public class DockerComposeContainer<SELF extends DockerComposeContainer<SELF>>

private List<String> services = new ArrayList<>();

private Set<ExposedService> exposedServices = new HashSet<>();

@Value
class ExposedService {
eddumelendez marked this conversation as resolved.
Show resolved Hide resolved

String name;

int port;

WaitStrategy waitStrategy;
}

/**
* Properties that should be passed through to all Compose and ambassador containers (not
* necessarily to containers that are spawned by Compose itself)
Expand All @@ -121,6 +136,8 @@ public class DockerComposeContainer<SELF extends DockerComposeContainer<SELF>>

private RemoveImages removeImages;

private boolean composeV2;

@Deprecated
public DockerComposeContainer(File composeFile, String identifier) {
this(identifier, composeFile);
Expand Down Expand Up @@ -186,12 +203,54 @@ public void start() {
log.warn("Exception while pulling images, using local images if available", e);
}
}
resolveDockerComposeVersion();
registerServices();
createServices();
startAmbassadorContainers();
waitUntilServiceStarted();
}
}

private void resolveDockerComposeVersion() {
if (this.localCompose) {
this.composeV2 = LocalDockerCompose.IS_COMPOSE_V2;
return;
}
this.composeV2 = TestcontainersConfiguration.getInstance().isComposeV2Enabled();
}

private void registerServices() {
this.exposedServices.forEach(exposedService -> {
eddumelendez marked this conversation as resolved.
Show resolved Hide resolved
String serviceInstanceName = getServiceInstanceName(exposedService.getName());

/*
* For every service/port pair that needs to be exposed, we register a target on an 'ambassador container'.
*
* The ambassador container's role is to link (within the Docker network) to one of the
* compose services, and proxy TCP network I/O out to a port that the ambassador container
* exposes.
*
* This avoids the need for the docker compose file to explicitly expose ports on all the
* services.
*
* {@link GenericContainer} should ensure that the ambassador container is on the same network
* as the rest of the compose environment.
*/

// Ambassador container will be started together after docker compose has started
int ambassadorPort = nextAmbassadorPort.getAndIncrement();
ambassadorPortMappings
.computeIfAbsent(serviceInstanceName, __ -> new ConcurrentHashMap<>())
.put(exposedService.getPort(), ambassadorPort);
ambassadorContainer.withTarget(ambassadorPort, serviceInstanceName, exposedService.getPort());
ambassadorContainer.addLink(
new FutureContainer(this.project + composeSeparator() + serviceInstanceName),
serviceInstanceName
);
addWaitStrategy(serviceInstanceName, exposedService.getWaitStrategy());
});
}

private void pullImages() {
// Pull images using our docker client rather than compose itself,
// (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
Expand Down Expand Up @@ -240,7 +299,7 @@ private void createServices() {
.distinct()
.collect(Collectors.joining(" "));

String command = optionsAsString() + "up -d";
String command = getUpCommand(optionsAsString());

if (build) {
command += " --build";
Expand All @@ -262,7 +321,7 @@ private String optionsAsString() {
String optionsString = options.stream().collect(Collectors.joining(" "));
if (optionsString.length() != 0) {
// ensures that there is a space between the options and 'up' if options are passed.
return optionsString + " ";
return optionsString;
} else {
// otherwise two spaces would appear between 'docker-compose' and 'up'
return StringUtils.EMPTY;
Expand Down Expand Up @@ -320,7 +379,7 @@ private void waitUntilServiceStarted(String serviceName, ComposeServiceWaitStrat
private String getServiceNameFromContainer(Container container) {
final String containerName = container.getLabels().get("com.docker.compose.service");
final String containerNumber = container.getLabels().get("com.docker.compose.container-number");
return String.format("%s_%s", containerName, containerNumber);
return String.format("%s%s%s", containerName, composeSeparator(), containerNumber);
}

private void runWithCompose(String cmd) {
Expand All @@ -331,7 +390,10 @@ private void runWithCompose(String cmd) {
if (localCompose) {
dockerCompose = new LocalDockerCompose(composeFiles, project);
} else {
dockerCompose = new ContainerisedDockerCompose(composeFiles, project);
DockerImageName composeImageName = this.composeV2
? ContainerisedDockerCompose.DEFAULT_IMAGE_NAME
: ContainerisedDockerCompose.DEFAULT_COMPOSE_IMAGE_NAME;
dockerCompose = new ContainerisedDockerCompose(composeImageName, composeFiles, project);
}

dockerCompose.withCommand(cmd).withEnv(env).invoke();
Expand Down Expand Up @@ -368,7 +430,7 @@ public void stop() {
ambassadorContainer.stop();

// Kill the services using docker-compose
String cmd = "down -v";
String cmd = getDownCommand();
if (removeImages != null) {
cmd += " --rmi " + removeImages.dockerRemoveImagesType();
}
Expand All @@ -384,7 +446,7 @@ public SELF withExposedService(String serviceName, int servicePort) {
}

public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort) {
return withExposedService(serviceName + "_" + instance, servicePort);
return withExposedService(serviceName + composeSeparator() + instance, servicePort);
}

public DockerComposeContainer withExposedService(
Expand All @@ -393,49 +455,28 @@ public DockerComposeContainer withExposedService(
int servicePort,
WaitStrategy waitStrategy
) {
return withExposedService(serviceName + "_" + instance, servicePort, waitStrategy);
return withExposedService(serviceName + composeSeparator() + instance, servicePort, waitStrategy);
}

public SELF withExposedService(String serviceName, int servicePort, @NonNull WaitStrategy waitStrategy) {
String serviceInstanceName = getServiceInstanceName(serviceName);

/*
* For every service/port pair that needs to be exposed, we register a target on an 'ambassador container'.
*
* The ambassador container's role is to link (within the Docker network) to one of the
* compose services, and proxy TCP network I/O out to a port that the ambassador container
* exposes.
*
* This avoids the need for the docker compose file to explicitly expose ports on all the
* services.
*
* {@link GenericContainer} should ensure that the ambassador container is on the same network
* as the rest of the compose environment.
*/

// Ambassador container will be started together after docker compose has started
int ambassadorPort = nextAmbassadorPort.getAndIncrement();
ambassadorPortMappings
.computeIfAbsent(serviceInstanceName, __ -> new ConcurrentHashMap<>())
.put(servicePort, ambassadorPort);
ambassadorContainer.withTarget(ambassadorPort, serviceInstanceName, servicePort);
ambassadorContainer.addLink(new FutureContainer(this.project + "_" + serviceInstanceName), serviceInstanceName);
addWaitStrategy(serviceInstanceName, waitStrategy);
this.exposedServices.add(new ExposedService(serviceName, servicePort, waitStrategy));
return self();
}

private String getServiceInstanceName(String serviceName) {
String serviceInstanceName = serviceName;
if (!serviceInstanceName.matches(".*_[0-9]+")) {
serviceInstanceName += "_1"; // implicit first instance of this service
String regex = String.format(".*%s[0-9]+", composeSeparator());
if (!serviceInstanceName.matches(regex)) {
serviceInstanceName += String.format("%s1", composeSeparator()); // implicit first instance of this service
}
return serviceInstanceName;
}

/*
* can have multiple wait strategies for a single container, e.g. if waiting on several ports
* if no wait strategy is defined, the WaitAllStrategy will return immediately.
* The WaitAllStrategy uses the startup timeout for everything as a global maximum, but we expect timeouts to be handled by the inner strategies.
* The WaitAllStrategy uses the startup timeout for everything as a global maximum,
* but we expect timeouts to be handled by the inner strategies.
*/
private void addWaitStrategy(String serviceInstanceName, @NonNull WaitStrategy waitStrategy) {
final WaitAllStrategy waitAllStrategy = waitStrategyMap.computeIfAbsent(
Expand Down Expand Up @@ -506,6 +547,22 @@ public Integer getServicePort(String serviceName, Integer servicePort) {
}
}

private String getUpCommand(String options) {
if (options == null || options.equals("")) {
return this.composeV2 ? "compose up -d" : "up -d";
}
String cmd = this.composeV2 ? "compose %s up -d" : "%s up -d";
return String.format(cmd, options);
}

private String getDownCommand() {
return this.composeV2 ? "compose down -v" : "down -v";
}

private String composeSeparator() {
return this.composeV2 ? "-" : "_";
}

public SELF withScaledService(String serviceBaseName, int numInstances) {
scalingPreferences.put(serviceBaseName, numInstances);

Expand Down Expand Up @@ -665,12 +722,14 @@ interface DockerCompose {
*/
class ContainerisedDockerCompose extends GenericContainer<ContainerisedDockerCompose> implements DockerCompose {

public static final char UNIX_PATH_SEPERATOR = ':';
public static final char UNIX_PATH_SEPARATOR = ':';
kiview marked this conversation as resolved.
Show resolved Hide resolved

public static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker/compose:1.29.2");
public static final DockerImageName DEFAULT_COMPOSE_IMAGE_NAME = DockerImageName.parse("docker/compose:1.29.2");

public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
super(DEFAULT_IMAGE_NAME);
public static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker:20.10.17");

public ContainerisedDockerCompose(DockerImageName dockerImageName, List<File> composeFiles, String identifier) {
super(dockerImageName);
addEnv(ENV_PROJECT_NAME, identifier);

// Map the docker compose file into the container
Expand All @@ -685,7 +744,7 @@ public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
.map(MountableFile::getFilesystemPath)
.map(this::convertToUnixFilesystemPath)
.collect(Collectors.toList());
final String composeFileEnvVariableValue = Joiner.on(UNIX_PATH_SEPERATOR).join(absoluteDockerComposeFiles); // we always need the UNIX path separator
final String composeFileEnvVariableValue = Joiner.on(UNIX_PATH_SEPARATOR).join(absoluteDockerComposeFiles); // we always need the UNIX path separator
logger().debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue);
addEnv(ENV_COMPOSE_FILE, composeFileEnvVariableValue);
addFileSystemBind(pwd, containerPwd, BindMode.READ_WRITE);
Expand Down Expand Up @@ -754,6 +813,8 @@ class LocalDockerCompose implements DockerCompose {
? "docker-compose.exe"
: "docker-compose";

private static final String DOCKER_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker.exe" : "docker";

private final List<File> composeFiles;

private final String identifier;
Expand All @@ -762,6 +823,29 @@ class LocalDockerCompose implements DockerCompose {

private Map<String, String> env = new HashMap<>();

private static final String executable = resolveDockerComposeExecutable();

public static final boolean IS_COMPOSE_V2 = DOCKER_EXECUTABLE.equals(executable);

private static String resolveDockerComposeExecutable() {
try {
ProcessBuilder processBuilder = new ProcessBuilder(DOCKER_EXECUTABLE, "compose", "--help");
int exitValue = processBuilder.start().waitFor();
if (exitValue == 0) {
return DOCKER_EXECUTABLE;
}
} catch (IOException | InterruptedException e) {
try {
ProcessBuilder processBuilder = new ProcessBuilder(COMPOSE_EXECUTABLE, "--help");
int exitValue = processBuilder.start().waitFor();
if (exitValue == 0) {
return COMPOSE_EXECUTABLE;
}
} catch (IOException | InterruptedException ex) {}
}
throw new RuntimeException("docker or docker-compose commands were not found");
}

public LocalDockerCompose(List<File> composeFiles, String identifier) {
this.composeFiles = composeFiles;
this.identifier = identifier;
Expand All @@ -781,16 +865,14 @@ public DockerCompose withEnv(Map<String, String> env) {

@VisibleForTesting
static boolean executableExists() {
return CommandLine.executableExists(COMPOSE_EXECUTABLE);
return CommandLine.executableExists(executable);
}

@Override
public void invoke() {
// bail out early
if (!executableExists()) {
throw new ContainerLaunchException(
"Local Docker Compose not found. Is " + COMPOSE_EXECUTABLE + " on the PATH?"
);
throw new ContainerLaunchException("Local Docker Compose not found. Is " + executable + " on the PATH?");
}

final Map<String, String> environment = Maps.newHashMap(env);
Expand Down Expand Up @@ -827,10 +909,7 @@ public void invoke() {

logger().info("Local Docker Compose is running command: {}", cmd);

final List<String> command = Splitter
.onPattern(" ")
.omitEmptyStrings()
.splitToList(COMPOSE_EXECUTABLE + " " + cmd);
final List<String> command = Splitter.onPattern(" ").omitEmptyStrings().splitToList(executable + " " + cmd);

try {
new ProcessExecutor()
Expand Down Expand Up @@ -859,6 +938,6 @@ public void invoke() {
* @return a logger
*/
private Logger logger() {
return DockerLoggerFactory.getLogger(COMPOSE_EXECUTABLE);
return DockerLoggerFactory.getLogger(executable);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ public boolean environmentSupportsReuse() {
return Boolean.parseBoolean(getEnvVarOrUserProperty("testcontainers.reuse.enable", "false"));
}

public boolean isComposeV2Enabled() {
return Boolean.parseBoolean(getEnvVarOrUserProperty("testcontainers.composev2.enable", "false"));
eddumelendez marked this conversation as resolved.
Show resolved Hide resolved
}

public String getDockerClientStrategyClassName() {
// getConfigurable won't apply the TESTCONTAINERS_ prefix when looking for env vars if DOCKER_ appears at the beginning.
// Because of this overlap, and the desire to not change this specific TESTCONTAINERS_DOCKER_CLIENT_STRATEGY setting,
Expand Down
Loading