diff --git a/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java b/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java index ced612bfefa..26694409849 100644 --- a/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java +++ b/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java @@ -63,6 +63,8 @@ public class ImageFromDockerfile private Optional dockerfile = Optional.empty(); + private Optional buildContext = Optional.empty(); + private Optional target = Optional.empty(); private final Set> buildImageCmdModifiers = new LinkedHashSet<>(); @@ -173,16 +175,36 @@ public void onNext(BuildResponseItem item) { protected void configure(BuildImageCmd buildImageCmd) { buildImageCmd.withTags(Collections.singleton(getDockerImageName())); this.dockerFilePath.ifPresent(buildImageCmd::withDockerfilePath); - this.dockerfile.ifPresent(p -> { - buildImageCmd.withDockerfile(p.toFile()); - dependencyImageNames = new ParsedDockerfile(p).getDependencyImageNames(); - - if (dependencyImageNames.size() > 0) { - // if we'll be pre-pulling images, disable the built-in pull as it is not necessary and will fail for - // authenticated registries - buildImageCmd.withPull(false); - } - }); + Optional effectiveDockerfile = this.dockerfile; + if (this.buildContext.isPresent()) { + Path context = this.buildContext.get().toAbsolutePath().normalize(); + if (!this.transferables.isEmpty()) { + throw new IllegalStateException( + "withBuildContext cannot be combined with withFile*/withDockerfileFromBuilder, " + + "the build context directory is sent as-is" + ); + } + if (!effectiveDockerfile.isPresent()) { + effectiveDockerfile = Optional.of(context.resolve("Dockerfile")); + } + if (!effectiveDockerfile.get().toAbsolutePath().normalize().startsWith(context)) { + throw new IllegalArgumentException( + "Dockerfile " + effectiveDockerfile.get() + " must be located inside the build context " + context + ); + } + // must be set before withDockerfile, which otherwise uses the Dockerfile's parent directory as context + buildImageCmd.withBaseDirectory(context.toFile()); + } + effectiveDockerfile.ifPresent(p -> { + buildImageCmd.withDockerfile(p.toFile()); + dependencyImageNames = new ParsedDockerfile(p).getDependencyImageNames(); + + if (dependencyImageNames.size() > 0) { + // if we'll be pre-pulling images, disable the built-in pull as it is not necessary and will fail for + // authenticated registries + buildImageCmd.withPull(false); + } + }); this.buildArgs.forEach(buildImageCmd::withBuildArg); this.target.ifPresent(buildImageCmd::withTarget); @@ -269,6 +291,21 @@ public ImageFromDockerfile withDockerfile(Path dockerfile) { return this; } + /** + * Sets the directory on the test host to be sent as build context, like the context argument of {@code docker build}. + * A {@code .dockerignore} file in its root is honored. + *

+ * The Dockerfile defaults to {@code Dockerfile} in the root of the build context. Use {@link #withDockerfile(Path)} + * to select a different one, which must be located inside the build context. + * Cannot be combined with the {@code withFile*} methods. + * + * @param buildContext path to the build context directory on the test host. + */ + public ImageFromDockerfile withBuildContext(Path buildContext) { + this.buildContext = Optional.of(buildContext); + return this; + } + /** * Allow low level modifications of {@link BuildImageCmd}. * Warning: this does expose the underlying docker-java API so might change outside of our control. diff --git a/core/src/test/java/org/testcontainers/images/builder/BuildContextTest.java b/core/src/test/java/org/testcontainers/images/builder/BuildContextTest.java new file mode 100644 index 00000000000..9647ba5c049 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/builder/BuildContextTest.java @@ -0,0 +1,94 @@ +package org.testcontainers.images.builder; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BuildContextTest { + + @TempDir + Path context; + + /** + * A Gradle cache used as build context, with the Dockerfile in a subdirectory and the cache's lock files excluded. + */ + @Test + void shouldSendBuildContextDirectoryHonoringDockerignore() throws IOException { + write("caches/modules-2/files-2.1/org.example/lib/1.0/lib-1.0.jar", "jar"); + write("caches/modules-2/modules-2.lock", "lock"); + write("caches/journal-1/journal-1.lock", "lock"); + write("caches/journal-1/file-access.bin", "bin"); + write(".dockerignore", "**/*.lock\n"); + write("docker/Dockerfile", "FROM alpine:3.16\nCOPY caches /gradle/caches\n"); + + ImageFromDockerfile image = new ImageFromDockerfile() + .withBuildContext(context) + .withDockerfile(context.resolve("docker/Dockerfile")); + + assertThat(listFiles(image, "/gradle")) + .contains("/gradle/caches/modules-2/files-2.1/org.example/lib/1.0/lib-1.0.jar") + .contains("/gradle/caches/journal-1/file-access.bin") + .doesNotContain(".lock"); + } + + @Test + void shouldDefaultToDockerfileInBuildContextRoot() throws IOException { + write("Dockerfile", "FROM alpine:3.16\nCOPY . /context\n"); + write("included.txt", "included"); + write("excluded.txt", "excluded"); + write(".dockerignore", "excluded.txt\n"); + + ImageFromDockerfile image = new ImageFromDockerfile().withBuildContext(context); + + assertThat(listFiles(image, "/context")).contains("/context/included.txt").doesNotContain("excluded.txt"); + } + + @Test + void shouldRejectDockerfileOutsideBuildContext(@TempDir Path other) throws IOException { + Path dockerfile = Files.write(other.resolve("Dockerfile"), "FROM alpine:3.16\n".getBytes()); + + ImageFromDockerfile image = new ImageFromDockerfile().withBuildContext(context).withDockerfile(dockerfile); + + assertThatThrownBy(image::get).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldRejectBuildContextCombinedWithFiles() throws IOException { + write("Dockerfile", "FROM alpine:3.16\n"); + + ImageFromDockerfile image = new ImageFromDockerfile() + .withBuildContext(context) + .withFileFromString("extra.txt", "extra"); + + assertThatThrownBy(image::get).isInstanceOf(IllegalStateException.class); + } + + private void write(String relativePath, String content) throws IOException { + Path file = context.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.write(file, content.getBytes()); + } + + @SuppressWarnings("resource") + private static String listFiles(ImageFromDockerfile image, String directory) { + try ( + GenericContainer container = new GenericContainer<>(DockerImageName.parse(image.get())) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("find", directory, "-type", "f") + ) { + container.start(); + return container.getLogs(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/docs/features/creating_images.md b/docs/features/creating_images.md index 8a239d342cc..3a3a80d91df 100644 --- a/docs/features/creating_images.md +++ b/docs/features/creating_images.md @@ -80,6 +80,22 @@ By default the no-args constructor will use an image name of the form `testconta Normally Docker will automatically build an image from any `/Dockerfile` that it finds in the root of the build context. To override this behaviour, use `.withDockerfilePath("./Name-Of-Other-Dockerfile")`. +## Build context directory + +To send an existing directory as build context, the way `docker build

` does, use `.withBuildContext(path)`. +A `.dockerignore` file in the root of that directory is honored, so ignored files are never sent to the Docker daemon. + +The Dockerfile defaults to `Dockerfile` in the root of the build context. To use a different one, add +`.withDockerfile(path)`; it must be located inside the build context. + +```java +new ImageFromDockerfile() + .withBuildContext(Paths.get("build/docker-context")) + .withDockerfile(Paths.get("build/docker-context/docker/Dockerfile")); +``` + +`.withBuildContext` cannot be combined with the `.withFile*` methods or `.withDockerfileFromBuilder`. + ## Build Args [Build Args](https://docs.docker.com/engine/reference/builder/#arg) may be used to allow lightweight parameterization.