diff --git a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java index 4a1d24c7a4..ef9f69b6aa 100644 --- a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java +++ b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java @@ -17,18 +17,29 @@ import lombok.EqualsAndHashCode; import lombok.Value; +import org.jspecify.annotations.Nullable; import org.openrewrite.ExecutionContext; import org.openrewrite.Option; import org.openrewrite.Recipe; import org.openrewrite.TreeVisitor; +import org.openrewrite.docker.DockerIsoVisitor; import org.openrewrite.docker.trait.DockerFrom; +import org.openrewrite.docker.tree.Docker; +import org.openrewrite.internal.ListUtils; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Objects.requireNonNull; @EqualsAndHashCode(callSuper = false) @Value @@ -50,48 +61,284 @@ public class UpgradeDockerImageVersion extends Recipe { private static final int OLDEST_VERSION = 8; private static final Pattern VERSIONED_TAG = Pattern.compile("(\\d{1,3})(\\D.*)?"); + private static final String FROM_REPLACEMENTS = "fromReplacements"; + String displayName = "Upgrade Docker image Java version"; String description = "Upgrade Docker image tags to use the specified Java version. " + "Updates common Java Docker images including eclipse-temurin, amazoncorretto, azul/zulu-openjdk, " + "and others. Also migrates deprecated images (openjdk, adoptopenjdk) to eclipse-temurin, " + - "preserving any tag suffix such as `-jre-alpine`. Image references built from build arguments or " + - "environment variables are left untouched, as their value can not be determined statically. A digest " + - "pin is dropped when the tag is upgraded, as the stale digest would otherwise keep resolving to the " + - "old image."; + "preserving any tag suffix such as `-jre-alpine`. When a `FROM` is built from a build argument, the " + + "default value of the corresponding global `ARG` is upgraded instead, such that `ARG java_version=17` " + + "used as `FROM eclipse-temurin:${java_version}` becomes `ARG java_version=25`. Image references built " + + "from arguments without a default value are left untouched, as their value can not be determined " + + "statically. A digest pin is dropped when the tag is upgraded, as the stale digest would otherwise " + + "keep resolving to the old image."; @Override public TreeVisitor getVisitor() { if (version == null) { return TreeVisitor.noop(); } - return new DockerFrom.Matcher().asVisitor((image, ctx) -> { - String imageName = image.getImageName().orElse(""); - String tag = image.getTag().orElse(""); - if (containsVariable(imageName) || containsVariable(tag)) { - return image.getTree(); + return new DockerIsoVisitor() { + + @Override + public Docker.File visitFile(Docker.File file, ExecutionContext ctx) { + Map defaults = new HashMap<>(); + for (Docker.Arg arg : file.getGlobalArgs()) { + Docker.Literal literalDefault = literalDefault(arg); + if (literalDefault != null) { + defaults.put(arg.getName().getText(), QuotedText.of(literalDefault.getText()).getText()); + } + } + + ArgPlan plan = planUpgrades(file, defaults); + getCursor().putMessage(FROM_REPLACEMENTS, plan.getFromReplacements()); + Docker.File f = super.visitFile(file, ctx); + + Map upgrades = plan.getArgUpgrades(); + if (upgrades.isEmpty()) { + return f; + } + return f.withGlobalArgs(ListUtils.map(f.getGlobalArgs(), arg -> { + String upgraded = upgrades.get(arg.getName().getText()); + Docker.Literal literalDefault = literalDefault(arg); + if (upgraded == null || literalDefault == null) { + return arg; + } + String requoted = QuotedText.of(literalDefault.getText()).requote(upgraded); + return arg.withValue(requireNonNull(arg.getValue()) + .withContents(singletonList(literalDefault.withText(requoted)))); + })); } - Matcher matcher = VERSIONED_TAG.matcher(tag); - if (!matcher.matches()) { - return image.getTree(); + @Override + public Docker.From visitFrom(Docker.From from, ExecutionContext ctx) { + if (containsVariable(from.getImageName()) || containsVariable(from.getTag())) { + Map replacements = getCursor().getNearestMessage(FROM_REPLACEMENTS, emptyMap()); + return replacements.getOrDefault(from.getId(), from); + } + + DockerFrom image = new DockerFrom(getCursor()); + String newTag = upgradedTag(image.getTag().orElse("")); + if (newTag == null) { + return from; + } + String imageName = image.getImageName().orElse(""); + if (DEPRECATED_IMAGES.contains(imageName)) { + return image.withImageReference(NEW_IMAGE + ":" + newTag); + } + if (CURRENT_IMAGES.contains(imageName)) { + return image.withTag(newTag).withDigest(null); + } + return from; } - int currentVersion = Integer.parseInt(matcher.group(1)); - if (currentVersion < OLDEST_VERSION || version <= currentVersion) { - return image.getTree(); + }; + } + + /** + * Withholding an argument can rule out the images that depend on it, which in turn can withhold further arguments, + * so the whole file is planned over and over until the set of withheld arguments stops growing. Only then is it + * known which `FROM` instructions may be rewritten, as an image may not be moved to a tag that is never written. + */ + private ArgPlan planUpgrades(Docker.File file, Map defaults) { + Map upgrades = new HashMap<>(); + Map replacements = new HashMap<>(); + Set blocked = new HashSet<>(); + int blockedCount; + do { + blockedCount = blocked.size(); + upgrades.clear(); + replacements.clear(); + for (Docker.Stage stage : file.getStages()) { + Docker.From from = stage.getFrom(); + if (containsVariable(from.getImageName()) || containsVariable(from.getTag())) { + Docker.From planned = planFrom(from, defaults, upgrades, blocked); + if (planned != from) { + replacements.put(from.getId(), planned); + } + } } + } while (blockedCount < blocked.size()); + return new ArgPlan(upgrades, replacements); + } - String newTag = version + (matcher.group(2) == null ? "" : matcher.group(2)); - if (DEPRECATED_IMAGES.contains(imageName)) { - return image.withImageReference(NEW_IMAGE + ":" + newTag); + private Docker.From planFrom(Docker.From from, Map defaults, Map upgrades, Set blocked) { + String imageVariable = soleVariable(from.getImageName()); + String tagVariable = from.getTag() == null ? null : leadingVariable(from.getTag()); + String imageName = imageVariable == null ? + literalText(from.getImageName()) : + defaultValue(defaults, blocked, imageVariable); + if (imageName == null) { + return block(from, blocked, imageVariable, tagVariable); + } + + String tag; + boolean wholeReference = from.getTag() == null; + if (wholeReference) { + // A single argument holding the whole reference, as in `FROM ${BASE_IMAGE}` + String[] reference = imageVariable == null ? null : splitReference(imageName); + if (reference == null) { + return block(from, blocked, imageVariable, null); } - if (CURRENT_IMAGES.contains(imageName)) { - return image.withTag(newTag).withDigest(null); + imageName = reference[0]; + tag = reference[1]; + tagVariable = imageVariable; + } else { + tag = tagVariable == null ? literalText(from.getTag()) : defaultValue(defaults, blocked, tagVariable); + } + if (tag == null) { + return block(from, blocked, imageVariable, tagVariable); + } + + String newImageName = upgradedImageName(imageName); + String newTag = upgradedTag(tag); + if (newImageName == null || newTag == null) { + return block(from, blocked, imageVariable, tagVariable); + } + + if (wholeReference) { + upgrades.put(requireNonNull(imageVariable), newImageName + ":" + newTag); + return from.withDigest(null); + } + if (tagVariable == null) { + from = from.withTag(withText(requireNonNull(from.getTag()), newTag)); + } else { + upgrades.put(tagVariable, newTag); + } + if (!newImageName.equals(imageName)) { + if (imageVariable == null) { + from = from.withImageName(withText(from.getImageName(), newImageName)); + } else { + upgrades.put(imageVariable, newImageName); } - return image.getTree(); - }); + } + return from.withDigest(null); + } + + private @Nullable String upgradedImageName(String imageName) { + if (DEPRECATED_IMAGES.contains(imageName)) { + return NEW_IMAGE; + } + return CURRENT_IMAGES.contains(imageName) ? imageName : null; + } + + private @Nullable String upgradedTag(String tag) { + Matcher matcher = VERSIONED_TAG.matcher(tag); + if (!matcher.matches()) { + return null; + } + int currentVersion = Integer.parseInt(matcher.group(1)); + if (currentVersion < OLDEST_VERSION || version <= currentVersion) { + return null; + } + return version + (matcher.group(2) == null ? "" : matcher.group(2)); + } + + /** + * Withhold arguments feeding an image we leave alone, as a shared argument can not be bumped for one image alone. + */ + private static Docker.From block(Docker.From from, Set blocked, @Nullable String imageVariable, @Nullable String tagVariable) { + if (imageVariable != null) { + blocked.add(imageVariable); + } + if (tagVariable != null) { + blocked.add(tagVariable); + } + return from; + } + + private static @Nullable String defaultValue(Map defaults, Set blocked, String variable) { + return blocked.contains(variable) ? null : defaults.get(variable); } - private static boolean containsVariable(String imageReferencePart) { - return imageReferencePart.indexOf('$') != -1; + private static boolean containsVariable(Docker.@Nullable Argument argument) { + if (argument != null) { + for (Docker.ArgumentContent content : argument.getContents()) { + if (content instanceof Docker.EnvironmentVariable) { + return true; + } + } + } + return false; + } + + private static Docker.@Nullable Literal literalDefault(Docker.Arg arg) { + Docker.Argument value = arg.getValue(); + return value == null ? null : sole(value.getContents(), Docker.Literal.class); + } + + private static @Nullable String literalText(Docker.Argument argument) { + Docker.Literal literal = sole(argument.getContents(), Docker.Literal.class); + return literal == null ? null : literal.getText(); + } + + private static @Nullable String soleVariable(Docker.Argument argument) { + Docker.EnvironmentVariable variable = sole(argument.getContents(), Docker.EnvironmentVariable.class); + return variable == null ? null : variable.getName(); + } + + private static @Nullable String leadingVariable(Docker.Argument argument) { + List contents = argument.getContents(); + if (contents.isEmpty() || !(contents.get(0) instanceof Docker.EnvironmentVariable)) { + return null; + } + for (int i = 1; i < contents.size(); i++) { + if (!(contents.get(i) instanceof Docker.Literal)) { + return null; + } + } + return ((Docker.EnvironmentVariable) contents.get(0)).getName(); + } + + private static @Nullable T sole(List contents, Class type) { + if (contents.size() == 1 && type.isInstance(contents.get(0))) { + return type.cast(contents.get(0)); + } + return null; + } + + private static String @Nullable [] splitReference(String reference) { + int at = reference.indexOf('@'); + String withoutDigest = at == -1 ? reference : reference.substring(0, at); + int colon = withoutDigest.indexOf(':', withoutDigest.lastIndexOf('/') + 1); + if (colon == -1) { + return null; + } + return new String[]{withoutDigest.substring(0, colon), withoutDigest.substring(colon + 1)}; + } + + private static Docker.Argument withText(Docker.Argument argument, String text) { + Docker.Literal literal = requireNonNull(sole(argument.getContents(), Docker.Literal.class)); + return argument.withContents(singletonList(literal.withText(text))); + } + + @Value + private static class ArgPlan { + Map argUpgrades; + Map fromReplacements; + } + + /** + * The parser keeps any quotes around an {@code ARG} default value as part of the literal text, so they have to be + * taken off before matching a version, and put back on when writing the upgraded value. + */ + @Value + private static class QuotedText { + String quote; + String text; + + static QuotedText of(String source) { + if (source.length() > 1) { + char first = source.charAt(0); + if ((first == '"' || first == '\'') && source.charAt(source.length() - 1) == first) { + return new QuotedText(String.valueOf(first), source.substring(1, source.length() - 1)); + } + } + return new QuotedText("", source); + } + + String requote(String replacement) { + return quote + replacement + quote; + } } } diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index dde61456e9..8dc21248de 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -91,7 +91,7 @@ maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.U maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeBuildToJava24ForKotlin1x,Upgrade build to Java 24 for Kotlin 1.x,"Kotlin versions before 2.3 only support up to Java 24, and Kotlin 1.x cannot be safely upgraded automatically because crossing the K2 compiler default introduced in Kotlin 2.0 is a source-breaking change. Such modules are therefore capped at Java 24 and annotated with an explanation. Modules already on Kotlin 2.0-2.2 are instead bumped to Kotlin 2.3 by `UpgradeKotlinForJava25` so they can reach Java 25. Applies only to modules that actually compile Kotlin (i.e. contain `.kt` source files), so transitive `kotlin-stdlib` dependencies do not trigger the cap.",9,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeBuildToJava25,Upgrade build to Java 25 (non-Kotlin),"Upgrades build files to Java 25 for modules without Kotlin source files. This covers pure Java projects, including those that only pick up `kotlin-stdlib` transitively through another dependency.",9,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeBuildToJava25ForKotlin,Upgrade build to Java 25 for Kotlin 2.3+,Upgrades build files to Java 25 for Kotlin modules already on Kotlin 2.3 or later.,9,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, -maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeDockerImageVersion,Upgrade Docker image Java version,"Upgrade Docker image tags to use the specified Java version. Updates common Java Docker images including eclipse-temurin, amazoncorretto, azul/zulu-openjdk, and others. Also migrates deprecated images (openjdk, adoptopenjdk) to eclipse-temurin, preserving any tag suffix such as `-jre-alpine`. Image references built from build arguments or environment variables are left untouched, as their value can not be determined statically. A digest pin is dropped when the tag is upgraded, as the stale digest would otherwise keep resolving to the old image.",1,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,"[{""name"":""version"",""type"":""Integer"",""displayName"":""Java version"",""description"":""The Java version to upgrade to."",""example"":""11"",""required"":true}]", +maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeDockerImageVersion,Upgrade Docker image Java version,"Upgrade Docker image tags to use the specified Java version. Updates common Java Docker images including eclipse-temurin, amazoncorretto, azul/zulu-openjdk, and others. Also migrates deprecated images (openjdk, adoptopenjdk) to eclipse-temurin, preserving any tag suffix such as `-jre-alpine`. When a `FROM` is built from a build argument, the default value of the corresponding global `ARG` is upgraded instead, such that `ARG java_version=17` used as `FROM eclipse-temurin:${java_version}` becomes `ARG java_version=25`. Image references built from arguments without a default value are left untouched, as their value can not be determined statically. A digest pin is dropped when the tag is upgraded, as the stale digest would otherwise keep resolving to the old image.",1,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,"[{""name"":""version"",""type"":""Integer"",""displayName"":""Java version"",""description"":""The Java version to upgrade to."",""example"":""11"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeJavaVersion,Upgrade Java version,"Upgrade build plugin configuration to use the specified Java version. This recipe changes `java.toolchain.languageVersion` in `build.gradle(.kts)` of gradle projects, or maven-compiler-plugin target version and related settings. Will not downgrade if the version is newer than the specified version.",8,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,"[{""name"":""version"",""type"":""Integer"",""displayName"":""Java version"",""description"":""The Java version to upgrade to."",""example"":""11"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeKotlinForJava25,Upgrade Kotlin to 2.3 for Java 25 compatibility,"Only Kotlin 2.3 and later can target Java 25 bytecode, so modules on an older Kotlin are otherwise capped at Java 24. This recipe upgrades modules that compile Kotlin (i.e. contain `.kt` source files) and are already on Kotlin 2.0, 2.1, or 2.2 up to the latest Kotlin 2.3, so they can subsequently be migrated to Java 25. Modules on Kotlin 1.x are left untouched, as crossing the K2 compiler default introduced in Kotlin 2.0 is a source-breaking change that should not be applied automatically. As a safety net the module is also floored at Java 24: if the Kotlin upgrade cannot be applied (for instance because the version is managed externally by a parent or BOM), the module still lands on Java 24 rather than being left behind, and is raised the rest of the way to Java 25 only once it actually reaches Kotlin 2.3.",12,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,"[{""name"":""org.openrewrite.maven.table.MavenMetadataFailures"",""displayName"":""Maven metadata failures"",""instanceName"":""Maven metadata failures"",""description"":""Attempts to resolve maven metadata that failed."",""columns"":[{""name"":""group"",""type"":""String"",""displayName"":""Group id"",""description"":""The groupId of the artifact for which the metadata download failed.""},{""name"":""artifactId"",""type"":""String"",""displayName"":""Artifact id"",""description"":""The artifactId of the artifact for which the metadata download failed.""},{""name"":""version"",""type"":""String"",""displayName"":""Version"",""description"":""The version of the artifact for which the metadata download failed.""},{""name"":""mavenRepositoryUri"",""type"":""String"",""displayName"":""Maven repository"",""description"":""The URL of the Maven repository that the metadata download failed on.""},{""name"":""snapshots"",""type"":""String"",""displayName"":""Snapshots"",""description"":""Does the repository support snapshots.""},{""name"":""releases"",""type"":""String"",""displayName"":""Releases"",""description"":""Does the repository support releases.""},{""name"":""failure"",""type"":""String"",""displayName"":""Failure"",""description"":""The reason the metadata download failed.""}]}]" maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.UpgradeKotlinJvmTargetVersion,Upgrade Kotlin `jvmTarget` to match the Java version,Align the Kotlin `jvmTarget` with the project's Java version so the Kotlin compiler emits bytecode at the same level as `javac`. Covers `kotlin-maven-plugin` `` configuration and the Gradle `kotlinOptions { jvmTarget = ... }` / `compilerOptions { jvmTarget = ... }` blocks (Groovy and Kotlin DSL). Will not downgrade if the existing Kotlin target is higher than the requested version.,1,,,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,"[{""name"":""version"",""type"":""Integer"",""displayName"":""Java version"",""description"":""The Java version to align Kotlin's `jvmTarget` with."",""example"":""21"",""required"":true}]", diff --git a/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java b/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java index 12e37af70e..48405347d2 100644 --- a/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java +++ b/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java @@ -85,6 +85,257 @@ void doNotChangeVariableImageReferences(String from) { ); } + @CsvSource({ + // The argument holds the bare version + "java_version=17, eclipse-temurin:${java_version}, java_version=25, eclipse-temurin:${java_version}", + "java_version=17, eclipse-temurin:$java_version, java_version=25, eclipse-temurin:$java_version", + "JAVA_VERSION=11, eclipse-temurin:${JAVA_VERSION}-jre, JAVA_VERSION=25, eclipse-temurin:${JAVA_VERSION}-jre", + "JAVA_VERSION=8, amazoncorretto:${JAVA_VERSION}-alpine, JAVA_VERSION=25, amazoncorretto:${JAVA_VERSION}-alpine", + // The argument holds the version and a suffix + "IMAGE_TAG=11-jre-alpine, eclipse-temurin:${IMAGE_TAG}, IMAGE_TAG=25-jre-alpine, eclipse-temurin:${IMAGE_TAG}", + // The argument holds the whole image reference + "BASE_IMAGE=eclipse-temurin:11-jre, ${BASE_IMAGE}, BASE_IMAGE=eclipse-temurin:25-jre, ${BASE_IMAGE}", + "BASE_IMAGE=openjdk:11-jre, ${BASE_IMAGE}, BASE_IMAGE=eclipse-temurin:25-jre, ${BASE_IMAGE}", + "BASE_IMAGE=eclipse-temurin:11-jre@sha256:1234567890abcdef, ${BASE_IMAGE}, BASE_IMAGE=eclipse-temurin:25-jre, ${BASE_IMAGE}", + // The argument holds the image name only + "BASE_IMAGE=eclipse-temurin, ${BASE_IMAGE}:11-jre, BASE_IMAGE=eclipse-temurin, ${BASE_IMAGE}:25-jre", + "BASE_IMAGE=openjdk, ${BASE_IMAGE}:11-jre, BASE_IMAGE=eclipse-temurin, ${BASE_IMAGE}:25-jre", + // A quoted default value keeps its quotes + "JAVA_VERSION=\"11\", eclipse-temurin:${JAVA_VERSION}, JAVA_VERSION=\"25\", eclipse-temurin:${JAVA_VERSION}", + "IMAGE_TAG=\"11-jre\", eclipse-temurin:${IMAGE_TAG}, IMAGE_TAG=\"25-jre\", eclipse-temurin:${IMAGE_TAG}", + "BASE_IMAGE=\"openjdk:11-jre\", ${BASE_IMAGE}, BASE_IMAGE=\"eclipse-temurin:25-jre\", ${BASE_IMAGE}", + }) + @ParameterizedTest + void upgradeArgumentDefaultValue(String beforeArg, String beforeFrom, String afterArg, String afterFrom) { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG %s + FROM %s + """.formatted(beforeArg, beforeFrom), + """ + ARG %s + FROM %s + """.formatted(afterArg, afterFrom) + ) + ); + } + + @Test + void upgradeSingleQuotedArgumentDefaultValue() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG IMAGE_TAG='11-jre' + FROM eclipse-temurin:${IMAGE_TAG} + """, + """ + ARG IMAGE_TAG='25-jre' + FROM eclipse-temurin:${IMAGE_TAG} + """ + ) + ); + } + + @Test + void upgradeDeprecatedImageNameAlongsideArgumentDefaultValue() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG JAVA_VERSION=11 + FROM openjdk:${JAVA_VERSION}-jre + """, + """ + ARG JAVA_VERSION=25 + FROM eclipse-temurin:${JAVA_VERSION}-jre + """ + ) + ); + } + + @Test + void upgradeArgumentDefaultValueSharedByStages() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG JAVA_VERSION=11 + FROM eclipse-temurin:${JAVA_VERSION}-jdk AS builder + FROM eclipse-temurin:${JAVA_VERSION}-jre + """, + """ + ARG JAVA_VERSION=25 + FROM eclipse-temurin:${JAVA_VERSION}-jdk AS builder + FROM eclipse-temurin:${JAVA_VERSION}-jre + """ + ) + ); + } + + @Test + void dropDigestPinWhenUpgradingArgumentDefaultValue() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG JAVA_VERSION=11 + FROM eclipse-temurin:${JAVA_VERSION}-jre@sha256:1234567890abcdef + """, + """ + ARG JAVA_VERSION=25 + FROM eclipse-temurin:${JAVA_VERSION}-jre + """ + ) + ); + } + + @Test + void dropDigestPinWhenUpgradingArgumentHoldingWholeReference() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG BASE_IMAGE=eclipse-temurin:11-jre + FROM ${BASE_IMAGE}@sha256:1234567890abcdef + """, + """ + ARG BASE_IMAGE=eclipse-temurin:25-jre + FROM ${BASE_IMAGE} + """ + ) + ); + } + + @Test + void doNotUpgradeArgumentSharedWithAnImageWeDoNotUpgrade() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG VERSION=11 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void keepDigestPinWhenTheSharedArgumentIsNotUpgraded() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG VERSION=11 + FROM eclipse-temurin:${VERSION}@sha256:1234567890abcdef AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void keepDeprecatedImageWhenTheSharedArgumentIsNotUpgraded() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG VERSION=11 + FROM openjdk:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void keepImageArgumentWhenTheSharedVersionArgumentIsNotUpgraded() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG IMAGE=openjdk + ARG VERSION=11 + FROM ${IMAGE}:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void doNotUpgradeImageArgumentSharedWithAnUntaggedImage() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG BASE=openjdk + FROM ${BASE}:11-jre AS build + FROM ${BASE} + """ + ) + ); + } + + @Test + void doNotUpgradeImageArgumentSharedWithAnImageStuckOnItsTag() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG BASE=openjdk + FROM ${BASE}:11-jre AS build + FROM ${BASE}:latest + """ + ) + ); + } + + @CsvSource({ + // Arguments that are not used in a FROM are left alone + "JAVA_VERSION=11, eclipse-temurin:25-jre", + // Arguments for unrelated images are left alone + "NODE_VERSION=20, node:${NODE_VERSION}-alpine", + // Arguments already at or beyond the target version are left alone + "JAVA_VERSION=25, eclipse-temurin:${JAVA_VERSION}-jre", + "JAVA_VERSION=26, eclipse-temurin:${JAVA_VERSION}-jre", + // Arguments not holding a leading version are left alone + "JAVA_VERSION=latest, eclipse-temurin:${JAVA_VERSION}", + "JAVA_VERSION=\"latest\", eclipse-temurin:${JAVA_VERSION}", + "SUFFIX=-jre, eclipse-temurin:11${SUFFIX}", + // Arguments that only hold part of the image name are left alone + "REGISTRY=docker.io, ${REGISTRY}/eclipse-temurin:11-jre", + }) + @ParameterizedTest + void doNotChangeUnrelatedArgumentDefaultValues(String arg, String from) { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + ARG %s + FROM %s + """.formatted(arg, from) + ) + ); + } + + @Test + void doNotChangeArgumentDeclaredAfterFirstFrom() { + rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker( + """ + FROM eclipse-temurin:25-jre + ARG JAVA_VERSION=11 + RUN echo "${JAVA_VERSION}" + """ + ) + ); + } + @CsvSource({ // Unrelated images are left alone "FROM ubuntu:22.04",