Skip to content

Commit a321cfa

Browse files
Allow copy specific files to docker compose (#8848)
This commit adds support for a `withCopyFilesInContainer` method on `ComposeContainer` and `DockerComposeContainer`. It allows to specify what files or directories should be copied, instead of just copying all files. If not used, the current behavior is preserved. Fixes #8847 --------- Co-authored-by: Eddú Meléndez <[email protected]>
1 parent 6a07650 commit a321cfa

15 files changed

+310
-11
lines changed

core/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ dependencies {
121121
testImplementation files('testlib/repo/fakejar/fakejar/0/fakejar-0.jar')
122122

123123
testImplementation 'org.assertj:assertj-core:3.25.3'
124+
testImplementation 'io.rest-assured:rest-assured:5.4.0'
124125

125126
jarFileTestCompileOnly "org.projectlombok:lombok:${lombok.version}"
126127
jarFileTestAnnotationProcessor "org.projectlombok:lombok:${lombok.version}"

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

+10-2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public class ComposeContainer extends FailureDetectingExternalResource implement
6767

6868
private String project;
6969

70+
private List<String> filesInDirectory = new ArrayList<>();
71+
7072
public ComposeContainer(File... composeFiles) {
7173
this(Arrays.asList(composeFiles));
7274
}
@@ -134,7 +136,8 @@ public void start() {
134136
this.options,
135137
this.services,
136138
this.scalingPreferences,
137-
this.env
139+
this.env,
140+
this.filesInDirectory
138141
);
139142
this.composeDelegate.startAmbassadorContainer();
140143
this.composeDelegate.waitUntilServiceStarted(this.tailChildContainers);
@@ -165,7 +168,7 @@ public void stop() {
165168
if (removeImages != null) {
166169
cmd += " --rmi " + removeImages.dockerRemoveImagesType();
167170
}
168-
this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env);
171+
this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env, this.filesInDirectory);
169172
} finally {
170173
this.project = this.composeDelegate.randomProjectId();
171174
}
@@ -352,6 +355,11 @@ public ComposeContainer withStartupTimeout(Duration startupTimeout) {
352355
return this;
353356
}
354357

358+
public ComposeContainer withCopyFilesInContainer(String... fileCopyInclusions) {
359+
this.filesInDirectory = Arrays.asList(fileCopyInclusions);
360+
return this;
361+
}
362+
355363
public Optional<ContainerState> getContainerByServiceName(String serviceName) {
356364
return this.composeDelegate.getContainerByServiceName(serviceName);
357365
}

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

+12-5
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ void createServices(
126126
final Set<String> options,
127127
final List<String> services,
128128
final Map<String, Integer> scalingPreferences,
129-
Map<String, String> env
129+
Map<String, String> env,
130+
List<String> fileCopyInclusions
130131
) {
131132
// services that have been explicitly requested to be started. If empty, all services should be started.
132133
final String serviceNameArgs = Stream
@@ -160,7 +161,7 @@ void createServices(
160161
}
161162

162163
// Run the docker compose container, which starts up the services
163-
runWithCompose(localCompose, command, env);
164+
runWithCompose(localCompose, command, env, fileCopyInclusions);
164165
}
165166

166167
private String getUpCommand(String options) {
@@ -237,18 +238,24 @@ private String getServiceNameFromContainer(com.github.dockerjava.api.model.Conta
237238
}
238239

239240
public void runWithCompose(boolean localCompose, String cmd) {
240-
runWithCompose(localCompose, cmd, Collections.emptyMap());
241+
runWithCompose(localCompose, cmd, Collections.emptyMap(), Collections.emptyList());
241242
}
242243

243-
public void runWithCompose(boolean localCompose, String cmd, Map<String, String> env) {
244+
public void runWithCompose(
245+
boolean localCompose,
246+
String cmd,
247+
Map<String, String> env,
248+
List<String> fileCopyInclusions
249+
) {
244250
Preconditions.checkNotNull(composeFiles);
245251
Preconditions.checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided");
246252

247253
final DockerCompose dockerCompose;
248254
if (localCompose) {
249255
dockerCompose = new LocalDockerCompose(this.executable, composeFiles, project);
250256
} else {
251-
dockerCompose = new ContainerisedDockerCompose(this.defaultImageName, composeFiles, project);
257+
dockerCompose =
258+
new ContainerisedDockerCompose(this.defaultImageName, composeFiles, project, fileCopyInclusions);
252259
}
253260

254261
dockerCompose.withCommand(cmd).withEnv(env).invoke();

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

+22-2
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ class ContainerisedDockerCompose extends GenericContainer<ContainerisedDockerCom
2424

2525
public static final char UNIX_PATH_SEPARATOR = ':';
2626

27-
public ContainerisedDockerCompose(DockerImageName dockerImageName, List<File> composeFiles, String identifier) {
27+
public ContainerisedDockerCompose(
28+
DockerImageName dockerImageName,
29+
List<File> composeFiles,
30+
String identifier,
31+
List<String> fileCopyInclusions
32+
) {
2833
super(dockerImageName);
2934
addEnv(ENV_PROJECT_NAME, identifier);
3035

@@ -43,7 +48,22 @@ public ContainerisedDockerCompose(DockerImageName dockerImageName, List<File> co
4348
final String composeFileEnvVariableValue = Joiner.on(UNIX_PATH_SEPARATOR).join(absoluteDockerComposeFiles); // we always need the UNIX path separator
4449
logger().debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue);
4550
addEnv(ENV_COMPOSE_FILE, composeFileEnvVariableValue);
46-
withCopyFileToContainer(MountableFile.forHostPath(pwd), containerPwd);
51+
if (fileCopyInclusions.isEmpty()) {
52+
logger().info("Copying all files in {} into the container", pwd);
53+
withCopyFileToContainer(MountableFile.forHostPath(pwd), containerPwd);
54+
} else {
55+
// Always copy the compose file itself
56+
logger().info("Copying docker compose file: {}", dockerComposeBaseFile.getAbsolutePath());
57+
withCopyFileToContainer(
58+
MountableFile.forHostPath(dockerComposeBaseFile.getAbsolutePath()),
59+
convertToUnixFilesystemPath(dockerComposeBaseFile.getAbsolutePath())
60+
);
61+
for (String pathToCopy : fileCopyInclusions) {
62+
String hostPath = pwd + "/" + pathToCopy;
63+
logger().info("Copying inclusion file: {}", hostPath);
64+
withCopyFileToContainer(MountableFile.forHostPath(hostPath), convertToUnixFilesystemPath(hostPath));
65+
}
66+
}
4767

4868
// Ensure that compose can access docker. Since the container is assumed to be running on the same machine
4969
// as the docker daemon, just mapping the docker control socket is OK.

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

+10-2
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ public class DockerComposeContainer<SELF extends DockerComposeContainer<SELF>>
6868

6969
private String project;
7070

71+
private List<String> filesInDirectory = new ArrayList<>();
72+
7173
@Deprecated
7274
public DockerComposeContainer(File composeFile, String identifier) {
7375
this(identifier, composeFile);
@@ -140,7 +142,8 @@ public void start() {
140142
this.options,
141143
this.services,
142144
this.scalingPreferences,
143-
this.env
145+
this.env,
146+
this.filesInDirectory
144147
);
145148
this.composeDelegate.startAmbassadorContainer();
146149
this.composeDelegate.waitUntilServiceStarted(this.tailChildContainers);
@@ -172,7 +175,7 @@ public void stop() {
172175
if (removeImages != null) {
173176
cmd += " --rmi " + removeImages.dockerRemoveImagesType();
174177
}
175-
this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env);
178+
this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env, this.filesInDirectory);
176179
} finally {
177180
this.project = this.composeDelegate.randomProjectId();
178181
}
@@ -355,6 +358,11 @@ public SELF withStartupTimeout(Duration startupTimeout) {
355358
return self();
356359
}
357360

361+
public SELF withCopyFilesInContainer(String... fileCopyInclusions) {
362+
this.filesInDirectory = Arrays.asList(fileCopyInclusions);
363+
return self();
364+
}
365+
358366
public Optional<ContainerState> getContainerByServiceName(String serviceName) {
359367
return this.composeDelegate.getContainerByServiceName(serviceName);
360368
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package org.testcontainers.junit;
2+
3+
import io.restassured.RestAssured;
4+
import org.junit.Test;
5+
import org.testcontainers.containers.ComposeContainer;
6+
import org.testcontainers.containers.ContainerLaunchException;
7+
8+
import java.io.File;
9+
import java.io.IOException;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
13+
14+
public class ComposeContainerWithCopyFilesTest {
15+
16+
@Test
17+
public void testShouldCopyAllFilesByDefault() throws IOException {
18+
try (
19+
ComposeContainer environment = new ComposeContainer(
20+
new File("src/test/resources/compose-file-copy-inclusions/compose.yml")
21+
)
22+
.withExposedService("app", 8080)
23+
) {
24+
environment.start();
25+
26+
String response = readStringFromURL(environment);
27+
assertThat(response).isEqualTo("MY_ENV_VARIABLE: override");
28+
}
29+
}
30+
31+
@Test
32+
public void testWithFileCopyInclusionUsingFilePath() throws IOException {
33+
try (
34+
ComposeContainer environment = new ComposeContainer(
35+
new File("src/test/resources/compose-file-copy-inclusions/compose-root-only.yml")
36+
)
37+
.withExposedService("app", 8080)
38+
.withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", ".env")
39+
) {
40+
environment.start();
41+
42+
String response = readStringFromURL(environment);
43+
44+
// The `test/.env` file is not copied, now so we get the original value
45+
assertThat(response).isEqualTo("MY_ENV_VARIABLE: original");
46+
}
47+
}
48+
49+
@Test
50+
public void testWithFileCopyInclusionUsingDirectoryPath() throws IOException {
51+
try (
52+
ComposeContainer environment = new ComposeContainer(
53+
new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml")
54+
)
55+
.withExposedService("app", 8080)
56+
.withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", "test")
57+
) {
58+
environment.start();
59+
60+
String response = readStringFromURL(environment);
61+
// The test directory (with its contents) is copied, so we get the override
62+
assertThat(response).isEqualTo("MY_ENV_VARIABLE: override");
63+
}
64+
}
65+
66+
@Test
67+
public void testShouldNotBeAbleToStartIfNeededEnvFileIsNotCopied() {
68+
try (
69+
ComposeContainer environment = new ComposeContainer(
70+
new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml")
71+
)
72+
.withExposedService("app", 8080)
73+
.withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java")
74+
) {
75+
assertThatExceptionOfType(ContainerLaunchException.class)
76+
.isThrownBy(environment::start)
77+
.withMessageContaining("Container startup failed for image docker");
78+
}
79+
}
80+
81+
private static String readStringFromURL(ComposeContainer container) throws IOException {
82+
Integer servicePort = container.getServicePort("app-1", 8080);
83+
String serviceHost = container.getServiceHost("app-1", 8080);
84+
String requestURL = "http://" + serviceHost + ":" + servicePort + "/env";
85+
return RestAssured.get(requestURL).thenReturn().body().asString();
86+
}
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package org.testcontainers.junit;
2+
3+
import io.restassured.RestAssured;
4+
import org.junit.Test;
5+
import org.testcontainers.containers.DockerComposeContainer;
6+
7+
import java.io.File;
8+
import java.io.IOException;
9+
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
12+
public class DockerComposeContainerWithCopyFilesTest {
13+
14+
@Test
15+
public void testShouldCopyAllFilesByDefault() throws IOException {
16+
try (
17+
DockerComposeContainer environment = new DockerComposeContainer(
18+
new File("src/test/resources/compose-file-copy-inclusions/compose.yml")
19+
)
20+
.withExposedService("app", 8080)
21+
) {
22+
environment.start();
23+
24+
String response = readStringFromURL(environment);
25+
assertThat(response).isEqualTo("MY_ENV_VARIABLE: override");
26+
}
27+
}
28+
29+
@Test
30+
public void testWithFileCopyInclusionUsingFilePath() throws IOException {
31+
try (
32+
DockerComposeContainer environment = new DockerComposeContainer(
33+
new File("src/test/resources/compose-file-copy-inclusions/compose-root-only.yml")
34+
)
35+
.withExposedService("app", 8080)
36+
.withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", ".env")
37+
) {
38+
environment.start();
39+
40+
String response = readStringFromURL(environment);
41+
42+
// The `test/.env` file is not copied, now so we get the original value
43+
assertThat(response).isEqualTo("MY_ENV_VARIABLE: original");
44+
}
45+
}
46+
47+
@Test
48+
public void testWithFileCopyInclusionUsingDirectoryPath() throws IOException {
49+
try (
50+
DockerComposeContainer environment = new DockerComposeContainer(
51+
new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml")
52+
)
53+
.withExposedService("app", 8080)
54+
.withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", "test")
55+
) {
56+
environment.start();
57+
58+
String response = readStringFromURL(environment);
59+
// The test directory (with its contents) is copied, so we get the override
60+
assertThat(response).isEqualTo("MY_ENV_VARIABLE: override");
61+
}
62+
}
63+
64+
private static String readStringFromURL(DockerComposeContainer container) throws IOException {
65+
Integer servicePort = container.getServicePort("app_1", 8080);
66+
String serviceHost = container.getServiceHost("app_1", 8080);
67+
String requestURL = "http://" + serviceHost + ":" + servicePort + "/env";
68+
return RestAssured.get(requestURL).thenReturn().body().asString();
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MY_ENV_VARIABLE=original
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM jbangdev/jbang-action
2+
3+
WORKDIR /app
4+
COPY EnvVariableRestEndpoint.java .
5+
6+
RUN jbang export portable --force EnvVariableRestEndpoint.java
7+
8+
EXPOSE 8080
9+
CMD ["./EnvVariableRestEndpoint.java"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
///usr/bin/env jbang "$0" "$@" ; exit $?
2+
3+
import com.sun.net.httpserver.HttpServer;
4+
import com.sun.net.httpserver.HttpHandler;
5+
import com.sun.net.httpserver.HttpExchange;
6+
7+
import java.io.IOException;
8+
import java.io.OutputStream;
9+
import java.net.InetSocketAddress;
10+
11+
public class EnvVariableRestEndpoint {
12+
private static final String ENV_VARIABLE_NAME = "MY_ENV_VARIABLE";
13+
private static final int PORT = 8080;
14+
15+
public static void main(String[] args) throws IOException {
16+
HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
17+
server.createContext("/env", new EnvVariableHandler());
18+
server.setExecutor(null);
19+
server.start();
20+
System.out.println("Server started on port " + PORT);
21+
}
22+
23+
static class EnvVariableHandler implements HttpHandler {
24+
@Override
25+
public void handle(HttpExchange exchange) throws IOException {
26+
if ("GET".equals(exchange.getRequestMethod())) {
27+
String envValue = System.getenv(ENV_VARIABLE_NAME);
28+
String response = envValue != null
29+
? ENV_VARIABLE_NAME + ": " + envValue
30+
: "Environment variable " + ENV_VARIABLE_NAME + " not found";
31+
32+
exchange.sendResponseHeaders(200, response.length());
33+
try (OutputStream os = exchange.getResponseBody()) {
34+
os.write(response.getBytes());
35+
}
36+
} else {
37+
String response = "Method not allowed";
38+
exchange.sendResponseHeaders(405, response.length());
39+
try (OutputStream os = exchange.getResponseBody()) {
40+
os.write(response.getBytes());
41+
}
42+
}
43+
}
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
services:
2+
app:
3+
build: .
4+
ports:
5+
- "8080:8080"
6+
env_file:
7+
- '.env'

0 commit comments

Comments
 (0)