From 563b8c544c7c54790a8545d537cd726a2496fa2b Mon Sep 17 00:00:00 2001 From: Timur Rakhmatullin <174210871+TimurRakhmatullin86@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:16:10 -0700 Subject: [PATCH] Do not treat multi-stage build stage references as images to pull in ParsedDockerfile --- .../images/ParsedDockerfile.java | 30 ++++++++---- .../images/ParsedDockerfileTest.java | 47 +++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java b/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java index 8c4d7866ff4..75f14943809 100644 --- a/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java +++ b/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java @@ -9,11 +9,12 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; /** * Representation of a Dockerfile, with partial parsing for extraction of a minimal set of data. @@ -22,7 +23,7 @@ public class ParsedDockerfile { private static final Pattern FROM_LINE_PATTERN = Pattern.compile( - "FROM (?--[^\\s]+\\s)*(?[^\\s]+).*", + "FROM (?--[^\\s]+\\s)*(?[^\\s]+)(\\s+AS\\s+(?[^\\s]+))?.*", Pattern.CASE_INSENSITIVE ); @@ -57,12 +58,25 @@ private List read() { } private Set parse(List lines) { - Set imageNames = lines - .stream() - .map(FROM_LINE_PATTERN::matcher) - .filter(Matcher::matches) - .map(matcher -> matcher.group("image")) - .collect(Collectors.toSet()); + Set imageNames = new HashSet<>(); + Set buildStageNames = new HashSet<>(); + + for (String line : lines) { + Matcher matcher = FROM_LINE_PATTERN.matcher(line); + if (!matcher.matches()) { + continue; + } + // A FROM referring to a previously declared build stage (e.g. `FROM builder`) is an internal + // stage reference, not an external image to be pulled. Build stage names are case-insensitive. + String image = matcher.group("image"); + if (!buildStageNames.contains(image.toLowerCase(Locale.ROOT))) { + imageNames.add(image); + } + String stage = matcher.group("stage"); + if (stage != null) { + buildStageNames.add(stage.toLowerCase(Locale.ROOT)); + } + } if (!imageNames.isEmpty()) { log.debug("Found dependency images in Dockerfile {}: {}", dockerFilePath, imageNames); diff --git a/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java b/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java index e881f0167e2..bcfb612aab2 100644 --- a/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java +++ b/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java @@ -90,6 +90,53 @@ void ignoringExtraPlatformArgs() { .isEqualTo(Sets.newHashSet("someimage")); } + @Test + void doesNotTreatBuildStageReferencesAsDependencyImages() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM alpine:3.17 AS build", "RUN something", "FROM build", "RUN somethingelse") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("does not treat references to a previously declared build stage as images to pull") + .isEqualTo(Sets.newHashSet("alpine:3.17")); + } + + @Test + void extractsOnlyExternalImagesFromMultiStageDockerfile() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList( + "FROM maven:3.9-eclipse-temurin-17 AS build", + "COPY . /workspace", + "RUN mvn -f /workspace package", + "FROM eclipse-temurin:17-jre", + "COPY --from=build /workspace/target/app.jar /app.jar", + "CMD [\"java\", \"-jar\", \"/app.jar\"]" + ) + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("extracts only external images from a multi-stage Dockerfile") + .isEqualTo(Sets.newHashSet("maven:3.9-eclipse-temurin-17", "eclipse-temurin:17-jre")); + } + + @Test + void matchesBuildStageNamesCaseInsensitively() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM someimage As Build", "RUN something", "FROM BUILD", "RUN somethingelse") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("matches build stage names case-insensitively, as Docker does") + .isEqualTo(Sets.newHashSet("someimage")); + } + + @Test + void retainsImagesOfUnreferencedBuildStages() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM someimage AS unusedstage", "RUN something", "FROM nextimage", "RUN somethingelse") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("still extracts images of stages whose alias is never referenced") + .isEqualTo(Sets.newHashSet("someimage", "nextimage")); + } + @Test void handlesGracefullyIfNoFromLine() { final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(