From 260a6e1cfa87e507a98f20bda18f7c077724e02d Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 22 Aug 2026 02:56:50 +0200 Subject: [PATCH 1/4] Revert "Upgrade `ARG` default values used in Docker `FROM` instructions (#1213)" This reverts commit d33eca37bc404361a35ee51f3b6bd3e513ff0e54. The recipe reached for rewrite-docker API that no released version of rewrite carries yet. `Docker.Argument.getText()`, `getTextWithVariables()` and `hasEnvironmentVariables()` arrived in openrewrite/rewrite#8576, and `org.openrewrite.docker.trait.ImageName` in openrewrite/rewrite#8599, both landed 2026-08-21, one day after v8.90.3. The Moderne CLI loads the LST classes in its own classloader, so a recipe runs against the rewrite-docker the CLI bundles rather than the one this artifact resolves. CLI 4.6.3 bundles rewrite-docker 8.90.3, where `Docker.Argument` exposes only `getContents()` and `ImageName` does not exist. `visitFile` reads a global `ARG` through `getText()` and `visitFrom` opens with `hasEnvironmentVariables()`, so the first `FROM` of every Dockerfile would raise `NoSuchMethodError`, surfacing as error markup on every Dockerfile of every `UpgradeToJava*` run. Nothing is lost by waiting: v3.42.1 predates this commit, so the breakage has not shipped. The state restored here is the #1212 fix, which reads an image reference through `DockerFrom` alone and so links against 8.90.3. Reapplied in a follow-up PR, to merge once a rewrite release carries #8576, #8590 and #8599 and the CLI picks it up. --- .../migrate/UpgradeDockerImageVersion.java | 218 +--------- .../resources/META-INF/rewrite/recipes.csv | 2 +- .../UpgradeDockerImageVersionTest.java | 410 ++---------------- 3 files changed, 53 insertions(+), 577 deletions(-) diff --git a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java index 32c489aa3e..4a1d24c7a4 100644 --- a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java +++ b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java @@ -17,28 +17,18 @@ 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.trait.ImageName; -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.regex.Matcher; import java.util.regex.Pattern; import static java.util.Arrays.asList; -import static java.util.Collections.emptyMap; -import static java.util.Objects.requireNonNull; @EqualsAndHashCode(callSuper = false) @Value @@ -60,208 +50,48 @@ 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 ARG_DEFAULTS = "argDefaults"; - private static final String ARG_UPGRADES = "argUpgrades"; - 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`. 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."; + "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."; @Override public TreeVisitor getVisitor() { if (version == null) { return TreeVisitor.noop(); } - return new DockerIsoVisitor() { - - @Override - public Docker.File visitFile(Docker.File file, ExecutionContext ctx) { - Map defaults = new HashMap<>(); - for (Docker.Arg arg : file.getGlobalArgs()) { - String value = arg.getValue() == null ? null : arg.getValue().getText(); - if (value != null) { - defaults.put(arg.getName().getText(), value); - } - } - - Map upgrades = new HashMap<>(); - getCursor().putMessage(ARG_DEFAULTS, defaults); - getCursor().putMessage(ARG_UPGRADES, upgrades); - Docker.File f = super.visitFile(file, ctx); - if (upgrades.isEmpty()) { - return f; - } - return f.withGlobalArgs(ListUtils.map(f.getGlobalArgs(), arg -> { - String name = arg.getName().getText(); - String upgraded = upgrades.get(name); - // A name may be declared more than once; only the declaration the default was read from moves - if (upgraded == null || arg.getValue() == null || !defaults.get(name).equals(arg.getValue().getText())) { - return arg; - } - return arg.withValue(withText(arg.getValue(), upgraded)); - })); + 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(); } - @Override - public Docker.From visitFrom(Docker.From from, ExecutionContext ctx) { - if (containsVariable(from.getImageName()) || containsVariable(from.getTag())) { - return upgradeThroughArgs(from, - getCursor().getNearestMessage(ARG_DEFAULTS, emptyMap()), - getCursor().getNearestMessage(ARG_UPGRADES, new HashMap<>())); - } - - DockerFrom image = new DockerFrom(getCursor()); - String newTag = upgradedTag(image.getTag().orElse("")); - if (newTag == null) { - return from; - } - String imageName = image.getImageName().orElse(""); - String newImageName = upgradedImageName(imageName); - if (newImageName == null) { - return from; - } - if (!newImageName.equals(imageName)) { - return image.withImageReference(newImageName + ":" + newTag); - } - return image.withTag(newTag).withDigest(null); + Matcher matcher = VERSIONED_TAG.matcher(tag); + if (!matcher.matches()) { + return image.getTree(); } - }; - } - - private Docker.From upgradeThroughArgs(Docker.From from, Map defaults, Map upgrades) { - String imageVariable = soleVariable(from.getImageName()); - String tagVariable = from.getTag() == null ? null : leadingVariable(from.getTag()); - String imageName = imageVariable == null ? - from.getImageName().getTextWithVariables() : - defaults.get(imageVariable); - if (imageName == null) { - return from; - } - - 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 from; + int currentVersion = Integer.parseInt(matcher.group(1)); + if (currentVersion < OLDEST_VERSION || version <= currentVersion) { + return image.getTree(); } - imageName = reference[0]; - tag = reference[1]; - } else { - tag = tagVariable == null ? from.getTag().getText() : defaults.get(tagVariable); - } - if (tag == null) { - return from; - } - - String newImageName = upgradedImageName(imageName); - String newTag = upgradedTag(tag); - if (newImageName == null || newTag == null) { - return from; - } - 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(withRepository(from.getImageName(), imageName, newImageName)); - } else { - upgrades.put(imageVariable, newImageName); + String newTag = version + (matcher.group(2) == null ? "" : matcher.group(2)); + if (DEPRECATED_IMAGES.contains(imageName)) { + return image.withImageReference(NEW_IMAGE + ":" + newTag); } - } - return from.withDigest(null); - } - - private @Nullable String upgradedImageName(String imageName) { - ImageName parsed = ImageName.parse(imageName); - String path = parsed.getPath(); - if (DEPRECATED_IMAGES.contains(path)) { - String registry = parsed.getRegistry(); - return registry == null ? NEW_IMAGE : registry + '/' + NEW_IMAGE; - } - return CURRENT_IMAGES.contains(path) ? 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)); - } - - private static boolean containsVariable(Docker.@Nullable Argument argument) { - return argument != null && argument.hasEnvironmentVariables(); - } - - private static @Nullable String soleVariable(Docker.Argument argument) { - List contents = argument.getContents(); - return contents.size() == 1 && contents.get(0) instanceof Docker.EnvironmentVariable ? - ((Docker.EnvironmentVariable) contents.get(0)).getName() : null; - } - - 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 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)}; - } - - /// The registry an image is pulled from is left as written, which may be a variable, so only the trailing - /// repository is rewritten. - private static Docker.Argument withRepository(Docker.Argument imageName, String from, String to) { - String oldPath = ImageName.parse(from).getPath(); - String newPath = ImageName.parse(to).getPath(); - return imageName.withContents(ListUtils.mapLast(imageName.getContents(), content -> { - if (!(content instanceof Docker.Literal)) { - return content; + if (CURRENT_IMAGES.contains(imageName)) { + return image.withTag(newTag).withDigest(null); } - Docker.Literal literal = (Docker.Literal) content; - String text = literal.getText(); - return text.endsWith(oldPath) ? - literal.withText(text.substring(0, text.length() - oldPath.length()) + newPath) : - literal; - })); + return image.getTree(); + }); } - private static Docker.Argument withText(Docker.Argument argument, String text) { - return argument.withContents(ListUtils.mapLast(argument.getContents(), - content -> content instanceof Docker.Literal ? ((Docker.Literal) content).withText(text) : content)); + private static boolean containsVariable(String imageReferencePart) { + return imageReferencePart.indexOf('$') != -1; } - } diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 6b31ab0f71..61e7ca355c 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`. 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.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.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 8908b10b34..12e37af70e 100644 --- a/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java +++ b/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java @@ -15,23 +15,15 @@ */ package org.openrewrite.java.migrate; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.openrewrite.test.RecipeSpec; import org.openrewrite.test.RewriteTest; import static org.openrewrite.docker.Assertions.docker; class UpgradeDockerImageVersionTest implements RewriteTest { - @Override - public void defaults(RecipeSpec spec) { - spec.recipe(new UpgradeDockerImageVersion(25)); - } - @CsvSource({ // Deprecated images migrate to eclipse-temurin "openjdk, 8, eclipse-temurin, 17, 17", @@ -72,230 +64,42 @@ void upgradeDockerImage(String fromImage, String fromTag, String toImage, String } @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}", + "FROM ${IMAGE_NAME}:${IMAGE_TAG}", + "FROM $IMAGE_NAME:$IMAGE_TAG", + "FROM ${IMAGE_NAME}:11", + "FROM eclipse-temurin:${IMAGE_TAG}", + "FROM ${REGISTRY}/eclipse-temurin:11-jre", }) @ParameterizedTest - void upgradeArgumentDefaultValue(String beforeArg, String beforeFrom, String afterArg, String afterFrom) { - rewriteRun( - docker( - """ - ARG %s - FROM %s - """.formatted(beforeArg, beforeFrom), - """ - ARG %s - FROM %s - """.formatted(afterArg, afterFrom) - ) - ); - } - - @Test - void upgradeSingleQuotedArgumentDefaultValue() { - rewriteRun( - docker( - """ - ARG IMAGE_TAG='11-jre' - FROM eclipse-temurin:${IMAGE_TAG} - """, - """ - ARG IMAGE_TAG='25-jre' - FROM eclipse-temurin:${IMAGE_TAG} - """ - ) - ); - } - - @Test - void upgradeDeprecatedImageNameAlongsideArgumentDefaultValue() { + void doNotChangeVariableImageReferences(String from) { 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( - 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( - 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( - 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 upgradeArgumentSharedWithAnImageWeDoNotUpgrade() { - rewriteRun( - docker( - """ - ARG VERSION=11 - FROM eclipse-temurin:${VERSION} AS build - FROM node:${VERSION} - """, - """ - ARG VERSION=25 - FROM eclipse-temurin:${VERSION} AS build - FROM node:${VERSION} - """ - ) - ); - } - - @Test - void dropDigestPinWhenUpgradingASharedArgument() { - rewriteRun( - docker( - """ - ARG VERSION=11 - FROM eclipse-temurin:${VERSION}@sha256:1234567890abcdef AS build - FROM node:${VERSION} - """, - """ - ARG VERSION=25 - FROM eclipse-temurin:${VERSION} AS build - FROM node:${VERSION} - """ - ) - ); - } - - @Test - void upgradeDeprecatedImageAlongsideASharedArgument() { - rewriteRun( - docker( - """ - ARG VERSION=11 - FROM openjdk:${VERSION} AS build - FROM node:${VERSION} - """, - """ - ARG VERSION=25 - FROM eclipse-temurin:${VERSION} AS build - FROM node:${VERSION} - """ - ) - ); - } - - @Test - void upgradeImageAndVersionArgumentsSharedWithAnotherImage() { - rewriteRun( - docker( - """ - ARG IMAGE=openjdk - ARG VERSION=11 - FROM ${IMAGE}:${VERSION} AS build - FROM node:${VERSION} - """, - """ - ARG IMAGE=eclipse-temurin - ARG VERSION=25 - FROM ${IMAGE}:${VERSION} AS build - FROM node:${VERSION} - """ - ) - ); - } - - @Test - void upgradeImageArgumentSharedWithAnUntaggedImage() { - rewriteRun( - docker( - """ - ARG BASE=openjdk - FROM ${BASE}:11-jre AS build - FROM ${BASE} - """, - """ - ARG BASE=eclipse-temurin - FROM ${BASE}:25-jre AS build - FROM ${BASE} - """ + ARG IMAGE_NAME + ARG IMAGE_TAG + ARG REGISTRY + %s + """.formatted(from) ) ); } - @Test - void upgradeImageArgumentSharedWithAnImageStuckOnItsTag() { + @CsvSource({ + // Unrelated images are left alone + "FROM ubuntu:22.04", + "FROM node:20-alpine", + // Tags without a leading Java version are left alone + "FROM eclipse-temurin:latest", + // Already at or beyond the target version + "FROM eclipse-temurin:25-jre", + "FROM eclipse-temurin:26-jre", + }) + @ParameterizedTest + void doNotChangeUnrelatedImages(String from) { rewriteRun( - docker( - """ - ARG BASE=openjdk - FROM ${BASE}:11-jre AS build - FROM ${BASE}:latest - """, - """ - ARG BASE=eclipse-temurin - FROM ${BASE}:25-jre AS build - FROM ${BASE}:latest - """ - ) + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), + docker(from) ); } @@ -306,6 +110,7 @@ void upgradeImageArgumentSharedWithAnImageStuckOnItsTag() { @ParameterizedTest void dropStaleDigestPin(String before, String after) { rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker(before, after) ); } @@ -313,6 +118,7 @@ void dropStaleDigestPin(String before, String after) { @Test void changeLiteralImageAlongsideVariableImage() { rewriteRun( + spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker( """ ARG IMAGE_TAG @@ -327,164 +133,4 @@ void changeLiteralImageAlongsideVariableImage() { ) ); } - - @Test - void upgradeOnlyTheArgumentDeclarationHoldingTheDefault() { - rewriteRun( - docker( - """ - ARG JAVA_VERSION - ARG JAVA_VERSION=11 - FROM eclipse-temurin:${JAVA_VERSION} - """, - """ - ARG JAVA_VERSION - ARG JAVA_VERSION=25 - FROM eclipse-temurin:${JAVA_VERSION} - """ - ) - ); - } - - @CsvSource({ - // A registry the FROM spells out - "docker.io/eclipse-temurin:11-jre, docker.io/eclipse-temurin:25-jre", - "docker.io/openjdk:11-jre, docker.io/eclipse-temurin:25-jre", - "myregistry:5000/openjdk:11-jre, myregistry:5000/eclipse-temurin:25-jre", - "localhost/eclipse-temurin:11-jre, localhost/eclipse-temurin:25-jre", - // A repository name may not be uppercase, so an uppercase segment can only be a registry - "MyRegistry/openjdk:11-jre, MyRegistry/eclipse-temurin:25-jre", - // A registry the FROM builds from an argument - "${REGISTRY}/eclipse-temurin:11-jre, ${REGISTRY}/eclipse-temurin:25-jre", - "${REGISTRY}/openjdk:11-jre, ${REGISTRY}/eclipse-temurin:25-jre", - "${REGISTRY}/azul/zulu-openjdk:11-jdk, ${REGISTRY}/azul/zulu-openjdk:25-jdk", - }) - @ParameterizedTest - void upgradeAnImageBehindARegistry(String before, String after) { - rewriteRun( - docker( - """ - ARG REGISTRY - FROM %s - """.formatted(before), - """ - ARG REGISTRY - FROM %s - """.formatted(after) - ) - ); - } - - @Test - void upgradeArgumentDefaultValueBehindAVariableRegistry() { - rewriteRun( - docker( - """ - ARG REGISTRY - ARG JAVA_VERSION=11 - FROM ${REGISTRY}/eclipse-temurin:${JAVA_VERSION}-jre - """, - """ - ARG REGISTRY - ARG JAVA_VERSION=25 - FROM ${REGISTRY}/eclipse-temurin:${JAVA_VERSION}-jre - """ - ) - ); - } - - @Nested - class NoChange { - - @ValueSource(strings = { - "FROM ${IMAGE_NAME}:${IMAGE_TAG}", - "FROM $IMAGE_NAME:$IMAGE_TAG", - "FROM ${IMAGE_NAME}:11", - "FROM eclipse-temurin:${IMAGE_TAG}", - }) - @ParameterizedTest - void variableImageReferences(String from) { - rewriteRun( - docker( - """ - ARG IMAGE_NAME - ARG IMAGE_TAG - ARG REGISTRY - %s - """.formatted(from) - ) - ); - } - - @Test - void anArgumentDefaultingToAnotherVariable() { - rewriteRun( - docker( - """ - ARG DEFAULT_VERSION - ARG JAVA_VERSION=${DEFAULT_VERSION} - FROM eclipse-temurin:${JAVA_VERSION} - """ - ) - ); - } - - @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}", - // A leading segment that is not a registry host belongs to the repository name - "JAVA_VERSION=11, mycompany/eclipse-temurin:${JAVA_VERSION}-jre", - }) - @ParameterizedTest - void unrelatedArgumentDefaultValues(String arg, String from) { - rewriteRun( - docker( - """ - ARG %s - FROM %s - """.formatted(arg, from) - ) - ); - } - - @Test - void anArgumentDeclaredAfterTheFirstFrom() { - rewriteRun( - docker( - """ - FROM eclipse-temurin:25-jre - ARG JAVA_VERSION=11 - RUN echo "${JAVA_VERSION}" - """ - ) - ); - } - - @ValueSource(strings = { - // Unrelated images are left alone - "FROM ubuntu:22.04", - "FROM node:20-alpine", - // Tags without a leading Java version are left alone - "FROM eclipse-temurin:latest", - "FROM eclipse-temurin:", - // Already at or beyond the target version - "FROM eclipse-temurin:25-jre", - "FROM eclipse-temurin:26-jre", - }) - @ParameterizedTest - void unrelatedImages(String from) { - rewriteRun( - docker(from) - ); - } - } } From 5cc74068914d74b940ee034faf668876d9ba715b Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 22 Aug 2026 02:59:36 +0200 Subject: [PATCH 2/4] Upgrade `ARG` default values used in Docker `FROM` instructions Reapplies #1213, reverted in #1215 because the rewrite-docker API it reads `ARG` defaults through had not been released yet. Hold until a rewrite release carries openrewrite/rewrite#8576 (the `Docker.Argument` accessors), #8590 (the image reference grammar) and #8599 (`ImageName`), and confirm the Moderne CLI bundles that release, as the CLI loads the LST classes in its own classloader and so decides which rewrite-docker a recipe actually links against. --- .../migrate/UpgradeDockerImageVersion.java | 218 +++++++++- .../resources/META-INF/rewrite/recipes.csv | 2 +- .../UpgradeDockerImageVersionTest.java | 410 ++++++++++++++++-- 3 files changed, 577 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java index 4a1d24c7a4..32c489aa3e 100644 --- a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java +++ b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java @@ -17,18 +17,28 @@ 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.trait.ImageName; +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.regex.Matcher; import java.util.regex.Pattern; import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; +import static java.util.Objects.requireNonNull; @EqualsAndHashCode(callSuper = false) @Value @@ -50,48 +60,208 @@ 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 ARG_DEFAULTS = "argDefaults"; + private static final String ARG_UPGRADES = "argUpgrades"; + 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()) { + String value = arg.getValue() == null ? null : arg.getValue().getText(); + if (value != null) { + defaults.put(arg.getName().getText(), value); + } + } + + Map upgrades = new HashMap<>(); + getCursor().putMessage(ARG_DEFAULTS, defaults); + getCursor().putMessage(ARG_UPGRADES, upgrades); + Docker.File f = super.visitFile(file, ctx); + if (upgrades.isEmpty()) { + return f; + } + return f.withGlobalArgs(ListUtils.map(f.getGlobalArgs(), arg -> { + String name = arg.getName().getText(); + String upgraded = upgrades.get(name); + // A name may be declared more than once; only the declaration the default was read from moves + if (upgraded == null || arg.getValue() == null || !defaults.get(name).equals(arg.getValue().getText())) { + return arg; + } + return arg.withValue(withText(arg.getValue(), upgraded)); + })); } - 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())) { + return upgradeThroughArgs(from, + getCursor().getNearestMessage(ARG_DEFAULTS, emptyMap()), + getCursor().getNearestMessage(ARG_UPGRADES, new HashMap<>())); + } + + DockerFrom image = new DockerFrom(getCursor()); + String newTag = upgradedTag(image.getTag().orElse("")); + if (newTag == null) { + return from; + } + String imageName = image.getImageName().orElse(""); + String newImageName = upgradedImageName(imageName); + if (newImageName == null) { + return from; + } + if (!newImageName.equals(imageName)) { + return image.withImageReference(newImageName + ":" + newTag); + } + return image.withTag(newTag).withDigest(null); } - int currentVersion = Integer.parseInt(matcher.group(1)); - if (currentVersion < OLDEST_VERSION || version <= currentVersion) { - return image.getTree(); + }; + } + + private Docker.From upgradeThroughArgs(Docker.From from, Map defaults, Map upgrades) { + String imageVariable = soleVariable(from.getImageName()); + String tagVariable = from.getTag() == null ? null : leadingVariable(from.getTag()); + String imageName = imageVariable == null ? + from.getImageName().getTextWithVariables() : + defaults.get(imageVariable); + if (imageName == null) { + return from; + } + + 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 from; } + imageName = reference[0]; + tag = reference[1]; + } else { + tag = tagVariable == null ? from.getTag().getText() : defaults.get(tagVariable); + } + if (tag == null) { + return from; + } + + String newImageName = upgradedImageName(imageName); + String newTag = upgradedTag(tag); + if (newImageName == null || newTag == null) { + return from; + } - String newTag = version + (matcher.group(2) == null ? "" : matcher.group(2)); - if (DEPRECATED_IMAGES.contains(imageName)) { - return image.withImageReference(NEW_IMAGE + ":" + newTag); + 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(withRepository(from.getImageName(), imageName, newImageName)); + } else { + upgrades.put(imageVariable, newImageName); } - if (CURRENT_IMAGES.contains(imageName)) { - return image.withTag(newTag).withDigest(null); + } + return from.withDigest(null); + } + + private @Nullable String upgradedImageName(String imageName) { + ImageName parsed = ImageName.parse(imageName); + String path = parsed.getPath(); + if (DEPRECATED_IMAGES.contains(path)) { + String registry = parsed.getRegistry(); + return registry == null ? NEW_IMAGE : registry + '/' + NEW_IMAGE; + } + return CURRENT_IMAGES.contains(path) ? 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)); + } + + private static boolean containsVariable(Docker.@Nullable Argument argument) { + return argument != null && argument.hasEnvironmentVariables(); + } + + private static @Nullable String soleVariable(Docker.Argument argument) { + List contents = argument.getContents(); + return contents.size() == 1 && contents.get(0) instanceof Docker.EnvironmentVariable ? + ((Docker.EnvironmentVariable) contents.get(0)).getName() : null; + } + + 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 image.getTree(); - }); + } + return ((Docker.EnvironmentVariable) contents.get(0)).getName(); } - private static boolean containsVariable(String imageReferencePart) { - return imageReferencePart.indexOf('$') != -1; + 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)}; } + + /// The registry an image is pulled from is left as written, which may be a variable, so only the trailing + /// repository is rewritten. + private static Docker.Argument withRepository(Docker.Argument imageName, String from, String to) { + String oldPath = ImageName.parse(from).getPath(); + String newPath = ImageName.parse(to).getPath(); + return imageName.withContents(ListUtils.mapLast(imageName.getContents(), content -> { + if (!(content instanceof Docker.Literal)) { + return content; + } + Docker.Literal literal = (Docker.Literal) content; + String text = literal.getText(); + return text.endsWith(oldPath) ? + literal.withText(text.substring(0, text.length() - oldPath.length()) + newPath) : + literal; + })); + } + + private static Docker.Argument withText(Docker.Argument argument, String text) { + return argument.withContents(ListUtils.mapLast(argument.getContents(), + content -> content instanceof Docker.Literal ? ((Docker.Literal) content).withText(text) : content)); + } + } diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 61e7ca355c..6b31ab0f71 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..8908b10b34 100644 --- a/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java +++ b/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java @@ -15,15 +15,23 @@ */ package org.openrewrite.java.migrate; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.openrewrite.test.RecipeSpec; import org.openrewrite.test.RewriteTest; import static org.openrewrite.docker.Assertions.docker; class UpgradeDockerImageVersionTest implements RewriteTest { + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new UpgradeDockerImageVersion(25)); + } + @CsvSource({ // Deprecated images migrate to eclipse-temurin "openjdk, 8, eclipse-temurin, 17, 17", @@ -64,42 +72,230 @@ void upgradeDockerImage(String fromImage, String fromTag, String toImage, String } @CsvSource({ - "FROM ${IMAGE_NAME}:${IMAGE_TAG}", - "FROM $IMAGE_NAME:$IMAGE_TAG", - "FROM ${IMAGE_NAME}:11", - "FROM eclipse-temurin:${IMAGE_TAG}", - "FROM ${REGISTRY}/eclipse-temurin:11-jre", + // 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 doNotChangeVariableImageReferences(String from) { + void upgradeArgumentDefaultValue(String beforeArg, String beforeFrom, String afterArg, String afterFrom) { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker( """ - ARG IMAGE_NAME - ARG IMAGE_TAG - ARG REGISTRY - %s - """.formatted(from) + ARG %s + FROM %s + """.formatted(beforeArg, beforeFrom), + """ + ARG %s + FROM %s + """.formatted(afterArg, afterFrom) ) ); } - @CsvSource({ - // Unrelated images are left alone - "FROM ubuntu:22.04", - "FROM node:20-alpine", - // Tags without a leading Java version are left alone - "FROM eclipse-temurin:latest", - // Already at or beyond the target version - "FROM eclipse-temurin:25-jre", - "FROM eclipse-temurin:26-jre", - }) - @ParameterizedTest - void doNotChangeUnrelatedImages(String from) { + @Test + void upgradeSingleQuotedArgumentDefaultValue() { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), - docker(from) + 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( + docker( + """ + ARG JAVA_VERSION=11 + FROM openjdk:${JAVA_VERSION}-jre + """, + """ + ARG JAVA_VERSION=25 + FROM eclipse-temurin:${JAVA_VERSION}-jre + """ + ) + ); + } + + @Test + void upgradeArgumentDefaultValueSharedByStages() { + rewriteRun( + 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( + 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( + 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 upgradeArgumentSharedWithAnImageWeDoNotUpgrade() { + rewriteRun( + docker( + """ + ARG VERSION=11 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """, + """ + ARG VERSION=25 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void dropDigestPinWhenUpgradingASharedArgument() { + rewriteRun( + docker( + """ + ARG VERSION=11 + FROM eclipse-temurin:${VERSION}@sha256:1234567890abcdef AS build + FROM node:${VERSION} + """, + """ + ARG VERSION=25 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void upgradeDeprecatedImageAlongsideASharedArgument() { + rewriteRun( + docker( + """ + ARG VERSION=11 + FROM openjdk:${VERSION} AS build + FROM node:${VERSION} + """, + """ + ARG VERSION=25 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void upgradeImageAndVersionArgumentsSharedWithAnotherImage() { + rewriteRun( + docker( + """ + ARG IMAGE=openjdk + ARG VERSION=11 + FROM ${IMAGE}:${VERSION} AS build + FROM node:${VERSION} + """, + """ + ARG IMAGE=eclipse-temurin + ARG VERSION=25 + FROM ${IMAGE}:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void upgradeImageArgumentSharedWithAnUntaggedImage() { + rewriteRun( + docker( + """ + ARG BASE=openjdk + FROM ${BASE}:11-jre AS build + FROM ${BASE} + """, + """ + ARG BASE=eclipse-temurin + FROM ${BASE}:25-jre AS build + FROM ${BASE} + """ + ) + ); + } + + @Test + void upgradeImageArgumentSharedWithAnImageStuckOnItsTag() { + rewriteRun( + docker( + """ + ARG BASE=openjdk + FROM ${BASE}:11-jre AS build + FROM ${BASE}:latest + """, + """ + ARG BASE=eclipse-temurin + FROM ${BASE}:25-jre AS build + FROM ${BASE}:latest + """ + ) ); } @@ -110,7 +306,6 @@ void doNotChangeUnrelatedImages(String from) { @ParameterizedTest void dropStaleDigestPin(String before, String after) { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker(before, after) ); } @@ -118,7 +313,6 @@ void dropStaleDigestPin(String before, String after) { @Test void changeLiteralImageAlongsideVariableImage() { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker( """ ARG IMAGE_TAG @@ -133,4 +327,164 @@ void changeLiteralImageAlongsideVariableImage() { ) ); } + + @Test + void upgradeOnlyTheArgumentDeclarationHoldingTheDefault() { + rewriteRun( + docker( + """ + ARG JAVA_VERSION + ARG JAVA_VERSION=11 + FROM eclipse-temurin:${JAVA_VERSION} + """, + """ + ARG JAVA_VERSION + ARG JAVA_VERSION=25 + FROM eclipse-temurin:${JAVA_VERSION} + """ + ) + ); + } + + @CsvSource({ + // A registry the FROM spells out + "docker.io/eclipse-temurin:11-jre, docker.io/eclipse-temurin:25-jre", + "docker.io/openjdk:11-jre, docker.io/eclipse-temurin:25-jre", + "myregistry:5000/openjdk:11-jre, myregistry:5000/eclipse-temurin:25-jre", + "localhost/eclipse-temurin:11-jre, localhost/eclipse-temurin:25-jre", + // A repository name may not be uppercase, so an uppercase segment can only be a registry + "MyRegistry/openjdk:11-jre, MyRegistry/eclipse-temurin:25-jre", + // A registry the FROM builds from an argument + "${REGISTRY}/eclipse-temurin:11-jre, ${REGISTRY}/eclipse-temurin:25-jre", + "${REGISTRY}/openjdk:11-jre, ${REGISTRY}/eclipse-temurin:25-jre", + "${REGISTRY}/azul/zulu-openjdk:11-jdk, ${REGISTRY}/azul/zulu-openjdk:25-jdk", + }) + @ParameterizedTest + void upgradeAnImageBehindARegistry(String before, String after) { + rewriteRun( + docker( + """ + ARG REGISTRY + FROM %s + """.formatted(before), + """ + ARG REGISTRY + FROM %s + """.formatted(after) + ) + ); + } + + @Test + void upgradeArgumentDefaultValueBehindAVariableRegistry() { + rewriteRun( + docker( + """ + ARG REGISTRY + ARG JAVA_VERSION=11 + FROM ${REGISTRY}/eclipse-temurin:${JAVA_VERSION}-jre + """, + """ + ARG REGISTRY + ARG JAVA_VERSION=25 + FROM ${REGISTRY}/eclipse-temurin:${JAVA_VERSION}-jre + """ + ) + ); + } + + @Nested + class NoChange { + + @ValueSource(strings = { + "FROM ${IMAGE_NAME}:${IMAGE_TAG}", + "FROM $IMAGE_NAME:$IMAGE_TAG", + "FROM ${IMAGE_NAME}:11", + "FROM eclipse-temurin:${IMAGE_TAG}", + }) + @ParameterizedTest + void variableImageReferences(String from) { + rewriteRun( + docker( + """ + ARG IMAGE_NAME + ARG IMAGE_TAG + ARG REGISTRY + %s + """.formatted(from) + ) + ); + } + + @Test + void anArgumentDefaultingToAnotherVariable() { + rewriteRun( + docker( + """ + ARG DEFAULT_VERSION + ARG JAVA_VERSION=${DEFAULT_VERSION} + FROM eclipse-temurin:${JAVA_VERSION} + """ + ) + ); + } + + @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}", + // A leading segment that is not a registry host belongs to the repository name + "JAVA_VERSION=11, mycompany/eclipse-temurin:${JAVA_VERSION}-jre", + }) + @ParameterizedTest + void unrelatedArgumentDefaultValues(String arg, String from) { + rewriteRun( + docker( + """ + ARG %s + FROM %s + """.formatted(arg, from) + ) + ); + } + + @Test + void anArgumentDeclaredAfterTheFirstFrom() { + rewriteRun( + docker( + """ + FROM eclipse-temurin:25-jre + ARG JAVA_VERSION=11 + RUN echo "${JAVA_VERSION}" + """ + ) + ); + } + + @ValueSource(strings = { + // Unrelated images are left alone + "FROM ubuntu:22.04", + "FROM node:20-alpine", + // Tags without a leading Java version are left alone + "FROM eclipse-temurin:latest", + "FROM eclipse-temurin:", + // Already at or beyond the target version + "FROM eclipse-temurin:25-jre", + "FROM eclipse-temurin:26-jre", + }) + @ParameterizedTest + void unrelatedImages(String from) { + rewriteRun( + docker(from) + ); + } + } } From 15c8198c1aee77bd0a773d7eeef3422effa6f901 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 22 Aug 2026 11:52:41 +0200 Subject: [PATCH 3/4] Revert "Merge branch 'main' into tim/docker-arg-defaults-redo" This reverts commit 901210a4, keeping the branch side. Merging main in pulled #1215 across, and #1215 is the revert of the very commit this branch exists to reapply. The merge base still carried the `ARG` work and main had removed it, so the merge resolved to main's removal and emptied the branch: `git diff main...HEAD` came back with nothing, leaving the pull request proposing no change at all. Reverting the merge rather than dropping it also settles the branch. The merge stays in history, so main's revert counts as already merged here and undone on purpose. A later `main` merge brings its new commits without resurrecting the removal, which resetting the branch would leave it open to on the next `Update branch`. --- .../migrate/UpgradeDockerImageVersion.java | 218 +++++++++- .../resources/META-INF/rewrite/recipes.csv | 2 +- .../UpgradeDockerImageVersionTest.java | 410 ++++++++++++++++-- 3 files changed, 577 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java index 4a1d24c7a4..32c489aa3e 100644 --- a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java +++ b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java @@ -17,18 +17,28 @@ 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.trait.ImageName; +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.regex.Matcher; import java.util.regex.Pattern; import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; +import static java.util.Objects.requireNonNull; @EqualsAndHashCode(callSuper = false) @Value @@ -50,48 +60,208 @@ 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 ARG_DEFAULTS = "argDefaults"; + private static final String ARG_UPGRADES = "argUpgrades"; + 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()) { + String value = arg.getValue() == null ? null : arg.getValue().getText(); + if (value != null) { + defaults.put(arg.getName().getText(), value); + } + } + + Map upgrades = new HashMap<>(); + getCursor().putMessage(ARG_DEFAULTS, defaults); + getCursor().putMessage(ARG_UPGRADES, upgrades); + Docker.File f = super.visitFile(file, ctx); + if (upgrades.isEmpty()) { + return f; + } + return f.withGlobalArgs(ListUtils.map(f.getGlobalArgs(), arg -> { + String name = arg.getName().getText(); + String upgraded = upgrades.get(name); + // A name may be declared more than once; only the declaration the default was read from moves + if (upgraded == null || arg.getValue() == null || !defaults.get(name).equals(arg.getValue().getText())) { + return arg; + } + return arg.withValue(withText(arg.getValue(), upgraded)); + })); } - 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())) { + return upgradeThroughArgs(from, + getCursor().getNearestMessage(ARG_DEFAULTS, emptyMap()), + getCursor().getNearestMessage(ARG_UPGRADES, new HashMap<>())); + } + + DockerFrom image = new DockerFrom(getCursor()); + String newTag = upgradedTag(image.getTag().orElse("")); + if (newTag == null) { + return from; + } + String imageName = image.getImageName().orElse(""); + String newImageName = upgradedImageName(imageName); + if (newImageName == null) { + return from; + } + if (!newImageName.equals(imageName)) { + return image.withImageReference(newImageName + ":" + newTag); + } + return image.withTag(newTag).withDigest(null); } - int currentVersion = Integer.parseInt(matcher.group(1)); - if (currentVersion < OLDEST_VERSION || version <= currentVersion) { - return image.getTree(); + }; + } + + private Docker.From upgradeThroughArgs(Docker.From from, Map defaults, Map upgrades) { + String imageVariable = soleVariable(from.getImageName()); + String tagVariable = from.getTag() == null ? null : leadingVariable(from.getTag()); + String imageName = imageVariable == null ? + from.getImageName().getTextWithVariables() : + defaults.get(imageVariable); + if (imageName == null) { + return from; + } + + 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 from; } + imageName = reference[0]; + tag = reference[1]; + } else { + tag = tagVariable == null ? from.getTag().getText() : defaults.get(tagVariable); + } + if (tag == null) { + return from; + } + + String newImageName = upgradedImageName(imageName); + String newTag = upgradedTag(tag); + if (newImageName == null || newTag == null) { + return from; + } - String newTag = version + (matcher.group(2) == null ? "" : matcher.group(2)); - if (DEPRECATED_IMAGES.contains(imageName)) { - return image.withImageReference(NEW_IMAGE + ":" + newTag); + 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(withRepository(from.getImageName(), imageName, newImageName)); + } else { + upgrades.put(imageVariable, newImageName); } - if (CURRENT_IMAGES.contains(imageName)) { - return image.withTag(newTag).withDigest(null); + } + return from.withDigest(null); + } + + private @Nullable String upgradedImageName(String imageName) { + ImageName parsed = ImageName.parse(imageName); + String path = parsed.getPath(); + if (DEPRECATED_IMAGES.contains(path)) { + String registry = parsed.getRegistry(); + return registry == null ? NEW_IMAGE : registry + '/' + NEW_IMAGE; + } + return CURRENT_IMAGES.contains(path) ? 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)); + } + + private static boolean containsVariable(Docker.@Nullable Argument argument) { + return argument != null && argument.hasEnvironmentVariables(); + } + + private static @Nullable String soleVariable(Docker.Argument argument) { + List contents = argument.getContents(); + return contents.size() == 1 && contents.get(0) instanceof Docker.EnvironmentVariable ? + ((Docker.EnvironmentVariable) contents.get(0)).getName() : null; + } + + 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 image.getTree(); - }); + } + return ((Docker.EnvironmentVariable) contents.get(0)).getName(); } - private static boolean containsVariable(String imageReferencePart) { - return imageReferencePart.indexOf('$') != -1; + 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)}; } + + /// The registry an image is pulled from is left as written, which may be a variable, so only the trailing + /// repository is rewritten. + private static Docker.Argument withRepository(Docker.Argument imageName, String from, String to) { + String oldPath = ImageName.parse(from).getPath(); + String newPath = ImageName.parse(to).getPath(); + return imageName.withContents(ListUtils.mapLast(imageName.getContents(), content -> { + if (!(content instanceof Docker.Literal)) { + return content; + } + Docker.Literal literal = (Docker.Literal) content; + String text = literal.getText(); + return text.endsWith(oldPath) ? + literal.withText(text.substring(0, text.length() - oldPath.length()) + newPath) : + literal; + })); + } + + private static Docker.Argument withText(Docker.Argument argument, String text) { + return argument.withContents(ListUtils.mapLast(argument.getContents(), + content -> content instanceof Docker.Literal ? ((Docker.Literal) content).withText(text) : content)); + } + } diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 61e7ca355c..6b31ab0f71 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..8908b10b34 100644 --- a/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java +++ b/src/test/java/org/openrewrite/java/migrate/UpgradeDockerImageVersionTest.java @@ -15,15 +15,23 @@ */ package org.openrewrite.java.migrate; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.openrewrite.test.RecipeSpec; import org.openrewrite.test.RewriteTest; import static org.openrewrite.docker.Assertions.docker; class UpgradeDockerImageVersionTest implements RewriteTest { + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new UpgradeDockerImageVersion(25)); + } + @CsvSource({ // Deprecated images migrate to eclipse-temurin "openjdk, 8, eclipse-temurin, 17, 17", @@ -64,42 +72,230 @@ void upgradeDockerImage(String fromImage, String fromTag, String toImage, String } @CsvSource({ - "FROM ${IMAGE_NAME}:${IMAGE_TAG}", - "FROM $IMAGE_NAME:$IMAGE_TAG", - "FROM ${IMAGE_NAME}:11", - "FROM eclipse-temurin:${IMAGE_TAG}", - "FROM ${REGISTRY}/eclipse-temurin:11-jre", + // 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 doNotChangeVariableImageReferences(String from) { + void upgradeArgumentDefaultValue(String beforeArg, String beforeFrom, String afterArg, String afterFrom) { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker( """ - ARG IMAGE_NAME - ARG IMAGE_TAG - ARG REGISTRY - %s - """.formatted(from) + ARG %s + FROM %s + """.formatted(beforeArg, beforeFrom), + """ + ARG %s + FROM %s + """.formatted(afterArg, afterFrom) ) ); } - @CsvSource({ - // Unrelated images are left alone - "FROM ubuntu:22.04", - "FROM node:20-alpine", - // Tags without a leading Java version are left alone - "FROM eclipse-temurin:latest", - // Already at or beyond the target version - "FROM eclipse-temurin:25-jre", - "FROM eclipse-temurin:26-jre", - }) - @ParameterizedTest - void doNotChangeUnrelatedImages(String from) { + @Test + void upgradeSingleQuotedArgumentDefaultValue() { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), - docker(from) + 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( + docker( + """ + ARG JAVA_VERSION=11 + FROM openjdk:${JAVA_VERSION}-jre + """, + """ + ARG JAVA_VERSION=25 + FROM eclipse-temurin:${JAVA_VERSION}-jre + """ + ) + ); + } + + @Test + void upgradeArgumentDefaultValueSharedByStages() { + rewriteRun( + 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( + 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( + 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 upgradeArgumentSharedWithAnImageWeDoNotUpgrade() { + rewriteRun( + docker( + """ + ARG VERSION=11 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """, + """ + ARG VERSION=25 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void dropDigestPinWhenUpgradingASharedArgument() { + rewriteRun( + docker( + """ + ARG VERSION=11 + FROM eclipse-temurin:${VERSION}@sha256:1234567890abcdef AS build + FROM node:${VERSION} + """, + """ + ARG VERSION=25 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void upgradeDeprecatedImageAlongsideASharedArgument() { + rewriteRun( + docker( + """ + ARG VERSION=11 + FROM openjdk:${VERSION} AS build + FROM node:${VERSION} + """, + """ + ARG VERSION=25 + FROM eclipse-temurin:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void upgradeImageAndVersionArgumentsSharedWithAnotherImage() { + rewriteRun( + docker( + """ + ARG IMAGE=openjdk + ARG VERSION=11 + FROM ${IMAGE}:${VERSION} AS build + FROM node:${VERSION} + """, + """ + ARG IMAGE=eclipse-temurin + ARG VERSION=25 + FROM ${IMAGE}:${VERSION} AS build + FROM node:${VERSION} + """ + ) + ); + } + + @Test + void upgradeImageArgumentSharedWithAnUntaggedImage() { + rewriteRun( + docker( + """ + ARG BASE=openjdk + FROM ${BASE}:11-jre AS build + FROM ${BASE} + """, + """ + ARG BASE=eclipse-temurin + FROM ${BASE}:25-jre AS build + FROM ${BASE} + """ + ) + ); + } + + @Test + void upgradeImageArgumentSharedWithAnImageStuckOnItsTag() { + rewriteRun( + docker( + """ + ARG BASE=openjdk + FROM ${BASE}:11-jre AS build + FROM ${BASE}:latest + """, + """ + ARG BASE=eclipse-temurin + FROM ${BASE}:25-jre AS build + FROM ${BASE}:latest + """ + ) ); } @@ -110,7 +306,6 @@ void doNotChangeUnrelatedImages(String from) { @ParameterizedTest void dropStaleDigestPin(String before, String after) { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker(before, after) ); } @@ -118,7 +313,6 @@ void dropStaleDigestPin(String before, String after) { @Test void changeLiteralImageAlongsideVariableImage() { rewriteRun( - spec -> spec.recipe(new UpgradeDockerImageVersion(25)), docker( """ ARG IMAGE_TAG @@ -133,4 +327,164 @@ void changeLiteralImageAlongsideVariableImage() { ) ); } + + @Test + void upgradeOnlyTheArgumentDeclarationHoldingTheDefault() { + rewriteRun( + docker( + """ + ARG JAVA_VERSION + ARG JAVA_VERSION=11 + FROM eclipse-temurin:${JAVA_VERSION} + """, + """ + ARG JAVA_VERSION + ARG JAVA_VERSION=25 + FROM eclipse-temurin:${JAVA_VERSION} + """ + ) + ); + } + + @CsvSource({ + // A registry the FROM spells out + "docker.io/eclipse-temurin:11-jre, docker.io/eclipse-temurin:25-jre", + "docker.io/openjdk:11-jre, docker.io/eclipse-temurin:25-jre", + "myregistry:5000/openjdk:11-jre, myregistry:5000/eclipse-temurin:25-jre", + "localhost/eclipse-temurin:11-jre, localhost/eclipse-temurin:25-jre", + // A repository name may not be uppercase, so an uppercase segment can only be a registry + "MyRegistry/openjdk:11-jre, MyRegistry/eclipse-temurin:25-jre", + // A registry the FROM builds from an argument + "${REGISTRY}/eclipse-temurin:11-jre, ${REGISTRY}/eclipse-temurin:25-jre", + "${REGISTRY}/openjdk:11-jre, ${REGISTRY}/eclipse-temurin:25-jre", + "${REGISTRY}/azul/zulu-openjdk:11-jdk, ${REGISTRY}/azul/zulu-openjdk:25-jdk", + }) + @ParameterizedTest + void upgradeAnImageBehindARegistry(String before, String after) { + rewriteRun( + docker( + """ + ARG REGISTRY + FROM %s + """.formatted(before), + """ + ARG REGISTRY + FROM %s + """.formatted(after) + ) + ); + } + + @Test + void upgradeArgumentDefaultValueBehindAVariableRegistry() { + rewriteRun( + docker( + """ + ARG REGISTRY + ARG JAVA_VERSION=11 + FROM ${REGISTRY}/eclipse-temurin:${JAVA_VERSION}-jre + """, + """ + ARG REGISTRY + ARG JAVA_VERSION=25 + FROM ${REGISTRY}/eclipse-temurin:${JAVA_VERSION}-jre + """ + ) + ); + } + + @Nested + class NoChange { + + @ValueSource(strings = { + "FROM ${IMAGE_NAME}:${IMAGE_TAG}", + "FROM $IMAGE_NAME:$IMAGE_TAG", + "FROM ${IMAGE_NAME}:11", + "FROM eclipse-temurin:${IMAGE_TAG}", + }) + @ParameterizedTest + void variableImageReferences(String from) { + rewriteRun( + docker( + """ + ARG IMAGE_NAME + ARG IMAGE_TAG + ARG REGISTRY + %s + """.formatted(from) + ) + ); + } + + @Test + void anArgumentDefaultingToAnotherVariable() { + rewriteRun( + docker( + """ + ARG DEFAULT_VERSION + ARG JAVA_VERSION=${DEFAULT_VERSION} + FROM eclipse-temurin:${JAVA_VERSION} + """ + ) + ); + } + + @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}", + // A leading segment that is not a registry host belongs to the repository name + "JAVA_VERSION=11, mycompany/eclipse-temurin:${JAVA_VERSION}-jre", + }) + @ParameterizedTest + void unrelatedArgumentDefaultValues(String arg, String from) { + rewriteRun( + docker( + """ + ARG %s + FROM %s + """.formatted(arg, from) + ) + ); + } + + @Test + void anArgumentDeclaredAfterTheFirstFrom() { + rewriteRun( + docker( + """ + FROM eclipse-temurin:25-jre + ARG JAVA_VERSION=11 + RUN echo "${JAVA_VERSION}" + """ + ) + ); + } + + @ValueSource(strings = { + // Unrelated images are left alone + "FROM ubuntu:22.04", + "FROM node:20-alpine", + // Tags without a leading Java version are left alone + "FROM eclipse-temurin:latest", + "FROM eclipse-temurin:", + // Already at or beyond the target version + "FROM eclipse-temurin:25-jre", + "FROM eclipse-temurin:26-jre", + }) + @ParameterizedTest + void unrelatedImages(String from) { + rewriteRun( + docker(from) + ); + } + } } From 502d17d0607187558bf00894ceb8ae6cbdfa9f13 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 22 Aug 2026 12:14:13 +0200 Subject: [PATCH 4/4] Read an argument's text through `ArgumentContents` openrewrite/rewrite#8608 moves `getText()`, `getTextWithVariables()`, `getQuoteStyle()` and `hasEnvironmentVariables()` off `Docker.Argument` and into `org.openrewrite.docker.internal.ArgumentContents`, then drops them from the LST type. That matters because of how the Moderne CLI splits one rewrite-docker jar across two classloaders: `org.openrewrite.docker.tree` resolves to the rewrite-docker the CLI bundles, while recipes, traits and `internal` load child-first from the recipe artifact. Reading an argument through the LST type therefore linked against the CLI's copy, which is where #1215 came from. The helpers now sit on the recipe's side of that split and read only members that predate the CLIs in the field, so they travel with this artifact. `ImageName` already sits on that side, so it needed no change. Verified against the pull request rather than assumed: rewrite-docker built at 6f5fd253 and published locally, and every one of the fifteen `org.openrewrite.docker.tree` members this recipe links against confirmed present in the 8.90.3 jar CLI 4.6.3 bundles, matching on descriptor. --- .../java/migrate/UpgradeDockerImageVersion.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java index 32c489aa3e..a5bdba0529 100644 --- a/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java +++ b/src/main/java/org/openrewrite/java/migrate/UpgradeDockerImageVersion.java @@ -23,6 +23,7 @@ import org.openrewrite.Recipe; import org.openrewrite.TreeVisitor; import org.openrewrite.docker.DockerIsoVisitor; +import org.openrewrite.docker.internal.ArgumentContents; import org.openrewrite.docker.trait.DockerFrom; import org.openrewrite.docker.trait.ImageName; import org.openrewrite.docker.tree.Docker; @@ -85,7 +86,7 @@ public TreeVisitor getVisitor() { public Docker.File visitFile(Docker.File file, ExecutionContext ctx) { Map defaults = new HashMap<>(); for (Docker.Arg arg : file.getGlobalArgs()) { - String value = arg.getValue() == null ? null : arg.getValue().getText(); + String value = arg.getValue() == null ? null : ArgumentContents.text(arg.getValue()); if (value != null) { defaults.put(arg.getName().getText(), value); } @@ -102,7 +103,7 @@ public Docker.File visitFile(Docker.File file, ExecutionContext ctx) { String name = arg.getName().getText(); String upgraded = upgrades.get(name); // A name may be declared more than once; only the declaration the default was read from moves - if (upgraded == null || arg.getValue() == null || !defaults.get(name).equals(arg.getValue().getText())) { + if (upgraded == null || arg.getValue() == null || !defaults.get(name).equals(ArgumentContents.text(arg.getValue()))) { return arg; } return arg.withValue(withText(arg.getValue(), upgraded)); @@ -139,7 +140,7 @@ private Docker.From upgradeThroughArgs(Docker.From from, Map def String imageVariable = soleVariable(from.getImageName()); String tagVariable = from.getTag() == null ? null : leadingVariable(from.getTag()); String imageName = imageVariable == null ? - from.getImageName().getTextWithVariables() : + ArgumentContents.textWithVariables(from.getImageName()) : defaults.get(imageVariable); if (imageName == null) { return from; @@ -156,7 +157,7 @@ private Docker.From upgradeThroughArgs(Docker.From from, Map def imageName = reference[0]; tag = reference[1]; } else { - tag = tagVariable == null ? from.getTag().getText() : defaults.get(tagVariable); + tag = tagVariable == null ? ArgumentContents.text(from.getTag()) : defaults.get(tagVariable); } if (tag == null) { return from; @@ -210,7 +211,7 @@ private Docker.From upgradeThroughArgs(Docker.From from, Map def } private static boolean containsVariable(Docker.@Nullable Argument argument) { - return argument != null && argument.hasEnvironmentVariables(); + return argument != null && ArgumentContents.containsVariable(argument); } private static @Nullable String soleVariable(Docker.Argument argument) {