diff --git a/core/build.gradle b/core/build.gradle index ddc4c5ffacb..6d8b637af0e 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -121,6 +121,7 @@ dependencies { testImplementation files('testlib/repo/fakejar/fakejar/0/fakejar-0.jar') testImplementation 'org.assertj:assertj-core:3.25.3' + testImplementation 'io.rest-assured:rest-assured:5.4.0' jarFileTestCompileOnly "org.projectlombok:lombok:${lombok.version}" jarFileTestAnnotationProcessor "org.projectlombok:lombok:${lombok.version}" diff --git a/core/src/main/java/org/testcontainers/containers/ComposeContainer.java b/core/src/main/java/org/testcontainers/containers/ComposeContainer.java index a865e744dd6..5add02183ae 100644 --- a/core/src/main/java/org/testcontainers/containers/ComposeContainer.java +++ b/core/src/main/java/org/testcontainers/containers/ComposeContainer.java @@ -67,6 +67,8 @@ public class ComposeContainer extends FailureDetectingExternalResource implement private String project; + private List filesInDirectory = new ArrayList<>(); + public ComposeContainer(File... composeFiles) { this(Arrays.asList(composeFiles)); } @@ -134,7 +136,8 @@ public void start() { this.options, this.services, this.scalingPreferences, - this.env + this.env, + this.filesInDirectory ); this.composeDelegate.startAmbassadorContainer(); this.composeDelegate.waitUntilServiceStarted(this.tailChildContainers); @@ -165,7 +168,7 @@ public void stop() { if (removeImages != null) { cmd += " --rmi " + removeImages.dockerRemoveImagesType(); } - this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env); + this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env, this.filesInDirectory); } finally { this.project = this.composeDelegate.randomProjectId(); } @@ -352,6 +355,11 @@ public ComposeContainer withStartupTimeout(Duration startupTimeout) { return this; } + public ComposeContainer withCopyFilesInContainer(String... fileCopyInclusions) { + this.filesInDirectory = Arrays.asList(fileCopyInclusions); + return this; + } + public Optional getContainerByServiceName(String serviceName) { return this.composeDelegate.getContainerByServiceName(serviceName); } diff --git a/core/src/main/java/org/testcontainers/containers/ComposeDelegate.java b/core/src/main/java/org/testcontainers/containers/ComposeDelegate.java index a5189c8efa3..0b09e9c2683 100644 --- a/core/src/main/java/org/testcontainers/containers/ComposeDelegate.java +++ b/core/src/main/java/org/testcontainers/containers/ComposeDelegate.java @@ -126,7 +126,8 @@ void createServices( final Set options, final List services, final Map scalingPreferences, - Map env + Map env, + List fileCopyInclusions ) { // services that have been explicitly requested to be started. If empty, all services should be started. final String serviceNameArgs = Stream @@ -160,7 +161,7 @@ void createServices( } // Run the docker compose container, which starts up the services - runWithCompose(localCompose, command, env); + runWithCompose(localCompose, command, env, fileCopyInclusions); } private String getUpCommand(String options) { @@ -237,10 +238,15 @@ private String getServiceNameFromContainer(com.github.dockerjava.api.model.Conta } public void runWithCompose(boolean localCompose, String cmd) { - runWithCompose(localCompose, cmd, Collections.emptyMap()); + runWithCompose(localCompose, cmd, Collections.emptyMap(), Collections.emptyList()); } - public void runWithCompose(boolean localCompose, String cmd, Map env) { + public void runWithCompose( + boolean localCompose, + String cmd, + Map env, + List fileCopyInclusions + ) { Preconditions.checkNotNull(composeFiles); Preconditions.checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided"); @@ -248,7 +254,8 @@ public void runWithCompose(boolean localCompose, String cmd, Map if (localCompose) { dockerCompose = new LocalDockerCompose(this.executable, composeFiles, project); } else { - dockerCompose = new ContainerisedDockerCompose(this.defaultImageName, composeFiles, project); + dockerCompose = + new ContainerisedDockerCompose(this.defaultImageName, composeFiles, project, fileCopyInclusions); } dockerCompose.withCommand(cmd).withEnv(env).invoke(); diff --git a/core/src/main/java/org/testcontainers/containers/ContainerisedDockerCompose.java b/core/src/main/java/org/testcontainers/containers/ContainerisedDockerCompose.java index e1b7aaa604b..7cff2c77271 100644 --- a/core/src/main/java/org/testcontainers/containers/ContainerisedDockerCompose.java +++ b/core/src/main/java/org/testcontainers/containers/ContainerisedDockerCompose.java @@ -24,7 +24,12 @@ class ContainerisedDockerCompose extends GenericContainer composeFiles, String identifier) { + public ContainerisedDockerCompose( + DockerImageName dockerImageName, + List composeFiles, + String identifier, + List fileCopyInclusions + ) { super(dockerImageName); addEnv(ENV_PROJECT_NAME, identifier); @@ -43,7 +48,22 @@ public ContainerisedDockerCompose(DockerImageName dockerImageName, List co 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); - withCopyFileToContainer(MountableFile.forHostPath(pwd), containerPwd); + if (fileCopyInclusions.isEmpty()) { + logger().info("Copying all files in {} into the container", pwd); + withCopyFileToContainer(MountableFile.forHostPath(pwd), containerPwd); + } else { + // Always copy the compose file itself + logger().info("Copying docker compose file: {}", dockerComposeBaseFile.getAbsolutePath()); + withCopyFileToContainer( + MountableFile.forHostPath(dockerComposeBaseFile.getAbsolutePath()), + convertToUnixFilesystemPath(dockerComposeBaseFile.getAbsolutePath()) + ); + for (String pathToCopy : fileCopyInclusions) { + String hostPath = pwd + "/" + pathToCopy; + logger().info("Copying inclusion file: {}", hostPath); + withCopyFileToContainer(MountableFile.forHostPath(hostPath), convertToUnixFilesystemPath(hostPath)); + } + } // Ensure that compose can access docker. Since the container is assumed to be running on the same machine // as the docker daemon, just mapping the docker control socket is OK. diff --git a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java index 68f9ec26ba6..748c0c59174 100644 --- a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java +++ b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java @@ -68,6 +68,8 @@ public class DockerComposeContainer> private String project; + private List filesInDirectory = new ArrayList<>(); + @Deprecated public DockerComposeContainer(File composeFile, String identifier) { this(identifier, composeFile); @@ -140,7 +142,8 @@ public void start() { this.options, this.services, this.scalingPreferences, - this.env + this.env, + this.filesInDirectory ); this.composeDelegate.startAmbassadorContainer(); this.composeDelegate.waitUntilServiceStarted(this.tailChildContainers); @@ -172,7 +175,7 @@ public void stop() { if (removeImages != null) { cmd += " --rmi " + removeImages.dockerRemoveImagesType(); } - this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env); + this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env, this.filesInDirectory); } finally { this.project = this.composeDelegate.randomProjectId(); } @@ -355,6 +358,11 @@ public SELF withStartupTimeout(Duration startupTimeout) { return self(); } + public SELF withCopyFilesInContainer(String... fileCopyInclusions) { + this.filesInDirectory = Arrays.asList(fileCopyInclusions); + return self(); + } + public Optional getContainerByServiceName(String serviceName) { return this.composeDelegate.getContainerByServiceName(serviceName); } diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerWithCopyFilesTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithCopyFilesTest.java new file mode 100644 index 00000000000..390ddd505fb --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithCopyFilesTest.java @@ -0,0 +1,87 @@ +package org.testcontainers.junit; + +import io.restassured.RestAssured; +import org.junit.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.ContainerLaunchException; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class ComposeContainerWithCopyFilesTest { + + @Test + public void testShouldCopyAllFilesByDefault() throws IOException { + try ( + ComposeContainer environment = new ComposeContainer( + new File("src/test/resources/compose-file-copy-inclusions/compose.yml") + ) + .withExposedService("app", 8080) + ) { + environment.start(); + + String response = readStringFromURL(environment); + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + @Test + public void testWithFileCopyInclusionUsingFilePath() throws IOException { + try ( + ComposeContainer environment = new ComposeContainer( + new File("src/test/resources/compose-file-copy-inclusions/compose-root-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", ".env") + ) { + environment.start(); + + String response = readStringFromURL(environment); + + // The `test/.env` file is not copied, now so we get the original value + assertThat(response).isEqualTo("MY_ENV_VARIABLE: original"); + } + } + + @Test + public void testWithFileCopyInclusionUsingDirectoryPath() throws IOException { + try ( + ComposeContainer environment = new ComposeContainer( + new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", "test") + ) { + environment.start(); + + String response = readStringFromURL(environment); + // The test directory (with its contents) is copied, so we get the override + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + @Test + public void testShouldNotBeAbleToStartIfNeededEnvFileIsNotCopied() { + try ( + ComposeContainer environment = new ComposeContainer( + new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java") + ) { + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(environment::start) + .withMessageContaining("Container startup failed for image docker"); + } + } + + private static String readStringFromURL(ComposeContainer container) throws IOException { + Integer servicePort = container.getServicePort("app-1", 8080); + String serviceHost = container.getServiceHost("app-1", 8080); + String requestURL = "http://" + serviceHost + ":" + servicePort + "/env"; + return RestAssured.get(requestURL).thenReturn().body().asString(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithCopyFilesTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithCopyFilesTest.java new file mode 100644 index 00000000000..46dd4c65b3c --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithCopyFilesTest.java @@ -0,0 +1,70 @@ +package org.testcontainers.junit; + +import io.restassured.RestAssured; +import org.junit.Test; +import org.testcontainers.containers.DockerComposeContainer; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DockerComposeContainerWithCopyFilesTest { + + @Test + public void testShouldCopyAllFilesByDefault() throws IOException { + try ( + DockerComposeContainer environment = new DockerComposeContainer( + new File("src/test/resources/compose-file-copy-inclusions/compose.yml") + ) + .withExposedService("app", 8080) + ) { + environment.start(); + + String response = readStringFromURL(environment); + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + @Test + public void testWithFileCopyInclusionUsingFilePath() throws IOException { + try ( + DockerComposeContainer environment = new DockerComposeContainer( + new File("src/test/resources/compose-file-copy-inclusions/compose-root-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", ".env") + ) { + environment.start(); + + String response = readStringFromURL(environment); + + // The `test/.env` file is not copied, now so we get the original value + assertThat(response).isEqualTo("MY_ENV_VARIABLE: original"); + } + } + + @Test + public void testWithFileCopyInclusionUsingDirectoryPath() throws IOException { + try ( + DockerComposeContainer environment = new DockerComposeContainer( + new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", "test") + ) { + environment.start(); + + String response = readStringFromURL(environment); + // The test directory (with its contents) is copied, so we get the override + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + private static String readStringFromURL(DockerComposeContainer container) throws IOException { + Integer servicePort = container.getServicePort("app_1", 8080); + String serviceHost = container.getServiceHost("app_1", 8080); + String requestURL = "http://" + serviceHost + ":" + servicePort + "/env"; + return RestAssured.get(requestURL).thenReturn().body().asString(); + } +} diff --git a/core/src/test/resources/compose-file-copy-inclusions/.env b/core/src/test/resources/compose-file-copy-inclusions/.env new file mode 100644 index 00000000000..bb5b5beb3e2 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/.env @@ -0,0 +1 @@ +MY_ENV_VARIABLE=original diff --git a/core/src/test/resources/compose-file-copy-inclusions/Dockerfile b/core/src/test/resources/compose-file-copy-inclusions/Dockerfile new file mode 100644 index 00000000000..851086d68c2 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/Dockerfile @@ -0,0 +1,9 @@ +FROM jbangdev/jbang-action + +WORKDIR /app +COPY EnvVariableRestEndpoint.java . + +RUN jbang export portable --force EnvVariableRestEndpoint.java + +EXPOSE 8080 +CMD ["./EnvVariableRestEndpoint.java"] diff --git a/core/src/test/resources/compose-file-copy-inclusions/EnvVariableRestEndpoint.java b/core/src/test/resources/compose-file-copy-inclusions/EnvVariableRestEndpoint.java new file mode 100644 index 00000000000..ead172bbb35 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/EnvVariableRestEndpoint.java @@ -0,0 +1,45 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpExchange; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; + +public class EnvVariableRestEndpoint { + private static final String ENV_VARIABLE_NAME = "MY_ENV_VARIABLE"; + private static final int PORT = 8080; + + public static void main(String[] args) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0); + server.createContext("/env", new EnvVariableHandler()); + server.setExecutor(null); + server.start(); + System.out.println("Server started on port " + PORT); + } + + static class EnvVariableHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("GET".equals(exchange.getRequestMethod())) { + String envValue = System.getenv(ENV_VARIABLE_NAME); + String response = envValue != null + ? ENV_VARIABLE_NAME + ": " + envValue + : "Environment variable " + ENV_VARIABLE_NAME + " not found"; + + exchange.sendResponseHeaders(200, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes()); + } + } else { + String response = "Method not allowed"; + exchange.sendResponseHeaders(405, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes()); + } + } + } + } +} diff --git a/core/src/test/resources/compose-file-copy-inclusions/compose-root-only.yml b/core/src/test/resources/compose-file-copy-inclusions/compose-root-only.yml new file mode 100644 index 00000000000..fa17d5f472d --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/compose-root-only.yml @@ -0,0 +1,7 @@ +services: + app: + build: . + ports: + - "8080:8080" + env_file: + - '.env' diff --git a/core/src/test/resources/compose-file-copy-inclusions/compose-test-only.yml b/core/src/test/resources/compose-file-copy-inclusions/compose-test-only.yml new file mode 100644 index 00000000000..943f908ce20 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/compose-test-only.yml @@ -0,0 +1,7 @@ +services: + app: + build: . + ports: + - "8080:8080" + env_file: + - './test/.env' diff --git a/core/src/test/resources/compose-file-copy-inclusions/compose.yml b/core/src/test/resources/compose-file-copy-inclusions/compose.yml new file mode 100644 index 00000000000..2a9334d7980 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/compose.yml @@ -0,0 +1,8 @@ +services: + app: + build: . + ports: + - "8080:8080" + env_file: + - '.env' + - './test/.env' diff --git a/core/src/test/resources/compose-file-copy-inclusions/test/.env b/core/src/test/resources/compose-file-copy-inclusions/test/.env new file mode 100644 index 00000000000..09867ae6d5d --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/test/.env @@ -0,0 +1 @@ +MY_ENV_VARIABLE=override diff --git a/docs/modules/docker_compose.md b/docs/modules/docker_compose.md index 10f1aee61aa..9032e7c6ee2 100644 --- a/docs/modules/docker_compose.md +++ b/docs/modules/docker_compose.md @@ -135,6 +135,26 @@ public static ComposeContainer environment = !!! note Make sure the service name use a `-` instead of `_` as separator using `ComposeContainer`. +## Build working directory + +You can select what files should be copied only via `withCopyFilesInContainer`: + +```java +public static ComposeContainer environment = + new ComposeContainer(new File("compose.yml")) + .withCopyFilesInContainer(".env"); +``` + +In this example, only `compose.yml` and `.env` are copied over into the container that will run the Docker Compose file. +By default, all files in the same directory as the compose file are copied over. + +This can be used with `DockerComposeContainer` and `ComposeContainer`. +You can use file and directory references. +They are always resolved relative to the directory where the compose file resides. + +!!! note + This only work with containarized Compose, not with `Local Compose` mode. + ## Using private repositories in Docker compose When Docker Compose is used in container mode (not local), it's needs to be made aware of Docker settings for private repositories. By default, those setting are located in `$HOME/.docker/config.json`.