Skip to content

Commit 7112db5

Browse files
eddumelendezkiview
andauthored
Add support for compose v2 with ComposeContainer (#5608)
[Compose V2](https://www.docker.com/blog/announcing-compose-v2-general-availability/) offers arm images to perform `docker compose` commands. Co-authored-by: Kevin Wittek <[email protected]>
1 parent d106d3f commit 7112db5

28 files changed

+2136
-542
lines changed

.github/labeler.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"area/docker-compose":
2+
- core/src/main/java/org/testcontainers/containers/ComposeContainer.java
3+
- core/src/main/java/org/testcontainers/containers/ComposeDelegate.java
24
- core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java
35
- core/src/main/java/org/testcontainers/containers/DockerComposeFiles.java
46
"github_actions":
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
package org.testcontainers.containers;
2+
3+
import com.github.dockerjava.api.model.Container;
4+
import com.google.common.annotations.VisibleForTesting;
5+
import lombok.NonNull;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.apache.commons.lang3.SystemUtils;
8+
import org.junit.runner.Description;
9+
import org.junit.runners.model.Statement;
10+
import org.testcontainers.containers.output.OutputFrame;
11+
import org.testcontainers.containers.wait.strategy.Wait;
12+
import org.testcontainers.containers.wait.strategy.WaitStrategy;
13+
import org.testcontainers.lifecycle.Startable;
14+
import org.testcontainers.utility.Base58;
15+
import org.testcontainers.utility.DockerImageName;
16+
17+
import java.io.File;
18+
import java.time.Duration;
19+
import java.util.ArrayList;
20+
import java.util.Arrays;
21+
import java.util.HashMap;
22+
import java.util.HashSet;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Optional;
26+
import java.util.Set;
27+
import java.util.function.Consumer;
28+
29+
/**
30+
* Testcontainers implementation for Docker Compose V2. <br>
31+
* It uses either Compose V2 contained within the Docker binary, or a containerised version of Compose V2.
32+
*/
33+
@Slf4j
34+
public class ComposeContainer extends FailureDetectingExternalResource implements Startable {
35+
36+
private final Map<String, Integer> scalingPreferences = new HashMap<>();
37+
38+
private boolean localCompose;
39+
40+
private boolean pull = true;
41+
42+
private boolean build = false;
43+
44+
private Set<String> options = new HashSet<>();
45+
46+
private boolean tailChildContainers;
47+
48+
private static final Object MUTEX = new Object();
49+
50+
private List<String> services = new ArrayList<>();
51+
52+
/**
53+
* Properties that should be passed through to all Compose and ambassador containers (not
54+
* necessarily to containers that are spawned by Compose itself)
55+
*/
56+
private Map<String, String> env = new HashMap<>();
57+
58+
private RemoveImages removeImages;
59+
60+
private boolean removeVolumes = true;
61+
62+
public static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker.exe" : "docker";
63+
64+
private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker:24.0.2");
65+
66+
private final ComposeDelegate composeDelegate;
67+
68+
private String project;
69+
70+
public ComposeContainer(File... composeFiles) {
71+
this(Arrays.asList(composeFiles));
72+
}
73+
74+
public ComposeContainer(List<File> composeFiles) {
75+
this(Base58.randomString(6).toLowerCase(), composeFiles);
76+
}
77+
78+
public ComposeContainer(String identifier, File... composeFiles) {
79+
this(identifier, Arrays.asList(composeFiles));
80+
}
81+
82+
public ComposeContainer(String identifier, List<File> composeFiles) {
83+
this.composeDelegate =
84+
new ComposeDelegate(
85+
ComposeDelegate.ComposeVersion.V2,
86+
composeFiles,
87+
identifier,
88+
COMPOSE_EXECUTABLE,
89+
DEFAULT_IMAGE_NAME
90+
);
91+
this.project = this.composeDelegate.getProject();
92+
}
93+
94+
@Override
95+
@Deprecated
96+
public Statement apply(Statement base, Description description) {
97+
return super.apply(base, description);
98+
}
99+
100+
@Override
101+
@Deprecated
102+
public void starting(Description description) {
103+
start();
104+
}
105+
106+
@Override
107+
@Deprecated
108+
protected void succeeded(Description description) {}
109+
110+
@Override
111+
@Deprecated
112+
protected void failed(Throwable e, Description description) {}
113+
114+
@Override
115+
@Deprecated
116+
public void finished(Description description) {
117+
stop();
118+
}
119+
120+
@Override
121+
public void start() {
122+
synchronized (MUTEX) {
123+
this.composeDelegate.registerContainersForShutdown();
124+
if (pull) {
125+
try {
126+
this.composeDelegate.pullImages();
127+
} catch (ContainerLaunchException e) {
128+
log.warn("Exception while pulling images, using local images if available", e);
129+
}
130+
}
131+
this.composeDelegate.createServices(
132+
this.localCompose,
133+
this.build,
134+
this.options,
135+
this.services,
136+
this.scalingPreferences,
137+
this.env
138+
);
139+
this.composeDelegate.startAmbassadorContainer();
140+
this.composeDelegate.waitUntilServiceStarted(this.tailChildContainers);
141+
}
142+
}
143+
144+
@VisibleForTesting
145+
List<Container> listChildContainers() {
146+
return this.composeDelegate.listChildContainers();
147+
}
148+
149+
public ComposeContainer withServices(@NonNull String... services) {
150+
this.services = Arrays.asList(services);
151+
return this;
152+
}
153+
154+
@Override
155+
public void stop() {
156+
synchronized (MUTEX) {
157+
try {
158+
this.composeDelegate.getAmbassadorContainer().stop();
159+
160+
// Kill the services using docker
161+
String cmd = "compose down";
162+
if (removeVolumes) {
163+
cmd += " -v";
164+
}
165+
if (removeImages != null) {
166+
cmd += " --rmi " + removeImages.dockerRemoveImagesType();
167+
}
168+
this.composeDelegate.runWithCompose(this.localCompose, cmd);
169+
} finally {
170+
this.project = this.composeDelegate.randomProjectId();
171+
}
172+
}
173+
}
174+
175+
public ComposeContainer withExposedService(String serviceName, int servicePort) {
176+
this.composeDelegate.withExposedService(serviceName, servicePort, Wait.defaultWaitStrategy());
177+
return this;
178+
}
179+
180+
public ComposeContainer withExposedService(String serviceName, int instance, int servicePort) {
181+
return withExposedService(serviceName + "-" + instance, servicePort);
182+
}
183+
184+
public ComposeContainer withExposedService(
185+
String serviceName,
186+
int instance,
187+
int servicePort,
188+
WaitStrategy waitStrategy
189+
) {
190+
this.composeDelegate.withExposedService(serviceName + "-" + instance, servicePort, waitStrategy);
191+
return this;
192+
}
193+
194+
public ComposeContainer withExposedService(
195+
String serviceName,
196+
int servicePort,
197+
@NonNull WaitStrategy waitStrategy
198+
) {
199+
this.composeDelegate.withExposedService(serviceName, servicePort, waitStrategy);
200+
return this;
201+
}
202+
203+
/**
204+
* Specify the {@link WaitStrategy} to use to determine if the container is ready.
205+
*
206+
* @param serviceName the name of the service to wait for
207+
* @param waitStrategy the WaitStrategy to use
208+
* @return this
209+
* @see org.testcontainers.containers.wait.strategy.Wait#defaultWaitStrategy()
210+
*/
211+
public ComposeContainer waitingFor(String serviceName, @NonNull WaitStrategy waitStrategy) {
212+
String serviceInstanceName = this.composeDelegate.getServiceInstanceName(serviceName);
213+
this.composeDelegate.addWaitStrategy(serviceInstanceName, waitStrategy);
214+
return this;
215+
}
216+
217+
/**
218+
* Get the host (e.g. IP address or hostname) that an exposed service can be found at, from the host machine
219+
* (i.e. should be the machine that's running this Java process).
220+
* <p>
221+
* The service must have been declared using ComposeContainer#withExposedService.
222+
*
223+
* @param serviceName the name of the service as set in the docker-compose.yml file.
224+
* @param servicePort the port exposed by the service container.
225+
* @return a host IP address or hostname that can be used for accessing the service container.
226+
*/
227+
public String getServiceHost(String serviceName, Integer servicePort) {
228+
return this.composeDelegate.getServiceHost();
229+
}
230+
231+
/**
232+
* Get the port that an exposed service can be found at, from the host machine
233+
* (i.e. should be the machine that's running this Java process).
234+
* <p>
235+
* The service must have been declared using ComposeContainer#withExposedService.
236+
*
237+
* @param serviceName the name of the service as set in the docker-compose.yml file.
238+
* @param servicePort the port exposed by the service container.
239+
* @return a port that can be used for accessing the service container.
240+
*/
241+
public Integer getServicePort(String serviceName, Integer servicePort) {
242+
return this.composeDelegate.getServicePort(serviceName, servicePort);
243+
}
244+
245+
public ComposeContainer withScaledService(String serviceBaseName, int numInstances) {
246+
scalingPreferences.put(serviceBaseName, numInstances);
247+
return this;
248+
}
249+
250+
public ComposeContainer withEnv(String key, String value) {
251+
env.put(key, value);
252+
return this;
253+
}
254+
255+
public ComposeContainer withEnv(Map<String, String> env) {
256+
env.forEach(this.env::put);
257+
return this;
258+
}
259+
260+
/**
261+
* Use a local Docker Compose binary instead of a container.
262+
*
263+
* @return this instance, for chaining
264+
*/
265+
public ComposeContainer withLocalCompose(boolean localCompose) {
266+
this.localCompose = localCompose;
267+
return this;
268+
}
269+
270+
/**
271+
* Whether to pull images first.
272+
*
273+
* @return this instance, for chaining
274+
*/
275+
public ComposeContainer withPull(boolean pull) {
276+
this.pull = pull;
277+
return this;
278+
}
279+
280+
/**
281+
* Whether to tail child container logs.
282+
*
283+
* @return this instance, for chaining
284+
*/
285+
public ComposeContainer withTailChildContainers(boolean tailChildContainers) {
286+
this.tailChildContainers = tailChildContainers;
287+
return this;
288+
}
289+
290+
/**
291+
* Attach an output consumer at container startup, enabling stdout and stderr to be followed, waited on, etc.
292+
* <p>
293+
* More than one consumer may be registered.
294+
*
295+
* @param serviceName the name of the service as set in the docker-compose.yml file
296+
* @param consumer consumer that output frames should be sent to
297+
* @return this instance, for chaining
298+
*/
299+
public ComposeContainer withLogConsumer(String serviceName, Consumer<OutputFrame> consumer) {
300+
this.composeDelegate.withLogConsumer(serviceName, consumer);
301+
return this;
302+
}
303+
304+
/**
305+
* Whether to always build images before starting containers.
306+
*
307+
* @return this instance, for chaining
308+
*/
309+
public ComposeContainer withBuild(boolean build) {
310+
this.build = build;
311+
return this;
312+
}
313+
314+
/**
315+
* Adds options to the docker command, e.g. docker --compatibility.
316+
*
317+
* @return this instance, for chaining
318+
*/
319+
public ComposeContainer withOptions(String... options) {
320+
this.options = new HashSet<>(Arrays.asList(options));
321+
return this;
322+
}
323+
324+
/**
325+
* Remove images after containers shutdown.
326+
*
327+
* @return this instance, for chaining
328+
*/
329+
public ComposeContainer withRemoveImages(ComposeContainer.RemoveImages removeImages) {
330+
this.removeImages = removeImages;
331+
return this;
332+
}
333+
334+
/**
335+
* Remove volumes after containers shut down.
336+
*
337+
* @param removeVolumes whether volumes are to be removed.
338+
* @return this instance, for chaining.
339+
*/
340+
public ComposeContainer withRemoveVolumes(boolean removeVolumes) {
341+
this.removeVolumes = removeVolumes;
342+
return this;
343+
}
344+
345+
/**
346+
* Set the maximum startup timeout all the waits set are bounded to.
347+
*
348+
* @return this instance. for chaining
349+
*/
350+
public ComposeContainer withStartupTimeout(Duration startupTimeout) {
351+
this.composeDelegate.setStartupTimeout(startupTimeout);
352+
return this;
353+
}
354+
355+
public Optional<ContainerState> getContainerByServiceName(String serviceName) {
356+
return this.composeDelegate.getContainerByServiceName(serviceName);
357+
}
358+
359+
private void followLogs(String containerId, Consumer<OutputFrame> consumer) {
360+
this.followLogs(containerId, consumer);
361+
}
362+
363+
public enum RemoveImages {
364+
/**
365+
* Remove all images used by any service.
366+
*/
367+
ALL("all"),
368+
369+
/**
370+
* Remove only images that don't have a custom tag set by the `image` field.
371+
*/
372+
LOCAL("local");
373+
374+
private final String dockerRemoveImagesType;
375+
376+
RemoveImages(final String dockerRemoveImagesType) {
377+
this.dockerRemoveImagesType = dockerRemoveImagesType;
378+
}
379+
380+
public String dockerRemoveImagesType() {
381+
return dockerRemoveImagesType;
382+
}
383+
}
384+
}

0 commit comments

Comments
 (0)