001/*
002 * Copyright 2017-2026 original authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package io.micronaut.maven.jdkaotcache;
017
018import com.github.dockerjava.api.command.InspectImageResponse;
019import com.github.dockerjava.api.model.ExposedPort;
020import com.github.dockerjava.api.model.InternetProtocol;
021import io.micronaut.core.annotation.Internal;
022import io.micronaut.maven.services.DockerService;
023import org.apache.maven.plugin.MojoExecutionException;
024import org.apache.maven.plugin.logging.Log;
025
026import java.io.IOException;
027import java.io.InputStream;
028import java.nio.charset.StandardCharsets;
029import java.nio.file.Files;
030import java.nio.file.Path;
031import java.util.ArrayList;
032import java.util.Arrays;
033import java.util.LinkedHashMap;
034import java.util.List;
035import java.util.Locale;
036import java.util.Map;
037import java.util.Objects;
038import java.util.Optional;
039import java.util.regex.Matcher;
040import java.util.regex.Pattern;
041
042/**
043 * Trains a JDK AOT cache in a container of the training image that the {@code docker} goal built with Jib. The
044 * container runs the image's own entrypoint, with {@code -XX:AOTCacheOutput} and {@code -XX:-UsePerfData} in
045 * {@code JDK_JAVA_OPTIONS}. What the application does in it is the {@link TrainingRun}:
046 * <ul>
047 *     <li>In a {@code load} run, it loads its bean definitions and exits without starting.</li>
048 *     <li>In a {@code start} run, if its Micronaut version has the training-run switch, it starts, warms
049 *     itself up and exits. It fails on a request without a response or with a status of 500 or more, and only warns
050 *     about a status from 400 to 499, so its output is copied to the build log when there are training paths.</li>
051 *     <li>Otherwise, it starts, the warm-up script runs in the container with {@code docker exec} and the application
052 *     is stopped with SIGTERM.</li>
053 * </ul>
054 *
055 * @author Álvaro Sánchez-Mariscal
056 * @since 5.1.0
057 */
058@Internal
059public final class JdkAotCacheTraining {
060
061    private static final String JDK_JAVA_OPTIONS = "JDK_JAVA_OPTIONS";
062    private static final String JDK_AOT_VM_OPTIONS = "JDK_AOT_VM_OPTIONS";
063
064    /**
065     * The minimum Java version that can train a JDK AOT cache in one step ({@code -XX:AOTCacheOutput}).
066     */
067    private static final int MINIMUM_JAVA_VERSION = 25;
068
069    /**
070     * The warm-up and training script, shipped with the plugin.
071     */
072    private static final String SCRIPT_RESOURCE = "/jdkAotCacheScripts/training.sh";
073
074    private static final String CONTAINER_CACHE_FILE = "/tmp/app.aot";
075    /**
076     * Keeps the training JVMs from writing performance data files ({@code /tmp/hsperfdata_<user>/<pid>}). The JVM that
077     * {@code -XX:AOTCacheOutput} starts to write the cache reads {@code JDK_JAVA_OPTIONS} too, and leaves its file
078     * behind. The training script passes the same option, because the layer of the {@code RUN} instruction that trains
079     * the cache in the generated Dockerfile would keep that file.
080     */
081    private static final String NO_PERF_DATA = "-XX:-UsePerfData";
082    private static final String COMPATIBLE_OOP_COMPRESSION_FLAG = "AOTCompatibleOopCompression";
083    private static final int SIGTERM_EXIT_CODE = 143;
084    private static final int COMMAND_NOT_EXECUTABLE = 126;
085    private static final int COMMAND_NOT_FOUND = 127;
086    private static final Pattern JAVA_VERSION = Pattern.compile(" version \"(\\d+)(?:\\.(\\d+))?");
087    private static final Pattern COMPATIBLE_OOP_COMPRESSION = Pattern.compile("(?m)^\\s*bool\\s+" + COMPATIBLE_OOP_COMPRESSION_FLAG + "\\s");
088
089    private final DockerService dockerService;
090    private final Log log;
091    private final List<String> trainingPaths;
092    private final int timeoutSeconds;
093    private final String networkMode;
094
095    /**
096     * @param dockerService the Docker service
097     * @param log the Maven log
098     * @param trainingPaths the paths of the warm-up GET requests
099     * @param timeoutSeconds the timeout of each step of the training run
100     * @param networkMode the network of the training container, if any
101     */
102    public JdkAotCacheTraining(DockerService dockerService, Log log, List<String> trainingPaths, int timeoutSeconds,
103                               String networkMode) {
104        this.dockerService = dockerService;
105        this.log = log;
106        this.trainingPaths = trainingPaths;
107        this.timeoutSeconds = timeoutSeconds;
108        this.networkMode = networkMode;
109    }
110
111    /**
112     * Runs the training image once and copies the cache it writes out of the container.
113     *
114     * @param imageId the training image
115     * @param run the training run
116     * @param cacheFile where to write the cache
117     * @throws MojoExecutionException if the training fails
118     */
119    public void train(String imageId, TrainingRun run, Path cacheFile) throws MojoExecutionException {
120        InspectImageResponse image = dockerService.inspectImage(imageId);
121        String[] imageEnvironment = image.getConfig() == null ? null : image.getConfig().getEnv();
122        JavaRuntime java = probeJava(imageId);
123        Integer port = run.usesSwitch() ? null : httpPort(image);
124        Map<String, String> environment = trainingEnvironment(imageEnvironment, java, run, trainingPaths);
125        environment.forEach((name, value) -> log.info("JDK AOT cache: training with " + name + "=" + value));
126
127        String containerId = dockerService.createContainer(imageId, networkMode, environment);
128        try {
129            if (run.mode() == TrainingMode.LOAD) {
130                log.info("JDK AOT cache: the application loads its bean definitions and exits without starting");
131                dockerService.startAndWait(containerId, imageId, timeoutSeconds);
132                // The summary of the run and the bean definitions it skipped say what the cache was trained on
133                dockerService.logContainerOutput(containerId);
134            } else if (run.usesSwitch()) {
135                log.info("JDK AOT cache: the application warms itself up and exits (Micronaut training-run switch)");
136                dockerService.startAndWait(containerId, imageId, timeoutSeconds);
137                if (!trainingPaths.isEmpty()) {
138                    // Micronaut only warns about a request answered with 400 to 499, in the output of the application
139                    dockerService.logContainerOutput(containerId);
140                }
141            } else {
142                log.info("JDK AOT cache: warming the application up with docker exec, then stopping it with SIGTERM "
143                    + "(the application's Micronaut version has no training-run switch)");
144                dockerService.startContainer(containerId);
145                // httpPort(image) set the port, because this run does not use the switch
146                warmUp(containerId, Objects.requireNonNull(port, "port"));
147                log.info("JDK AOT cache: stopping the application with SIGTERM");
148                dockerService.signalContainer(containerId, "SIGTERM");
149                int exitCode = dockerService.awaitExit(containerId, timeoutSeconds);
150                if (exitCode != SIGTERM_EXIT_CODE && exitCode != 0) {
151                    dockerService.logContainerOutput(containerId);
152                    throw new MojoExecutionException("JDK AOT cache training failed: the application exited with status "
153                        + exitCode + " after SIGTERM");
154                }
155            }
156            copyCache(containerId, cacheFile);
157        } catch (IOException e) {
158            throw new MojoExecutionException("JDK AOT cache training failed: " + e.getMessage(), e);
159        } finally {
160            dockerService.removeContainer(containerId);
161        }
162    }
163
164    /**
165     * @param paths the configured training paths
166     * @return the paths, trimmed
167     * @throws MojoExecutionException if a path is not an absolute request path
168     */
169    public static List<String> validateTrainingPaths(List<String> paths) throws MojoExecutionException {
170        if (paths == null) {
171            return List.of();
172        }
173        var result = new ArrayList<String>(paths.size());
174        for (String path : paths) {
175            String trimmed = path == null ? "" : path.trim();
176            if (trimmed.isEmpty()) {
177                continue;
178            }
179            if (!trimmed.startsWith("/") || trimmed.chars().anyMatch(c -> Character.isWhitespace(c) || Character.isISOControl(c))) {
180                throw new MojoExecutionException("Invalid micronaut.docker.jdkAotCache.trainingPaths entry '" + trimmed
181                    + "': it must start with / and contain no spaces or control characters");
182            }
183            result.add(trimmed);
184        }
185        return List.copyOf(result);
186    }
187
188    /**
189     * @return the warm-up and training script
190     * @throws IOException if the script cannot be read
191     */
192    static String readScript() throws IOException {
193        try (InputStream in = JdkAotCacheTraining.class.getResourceAsStream(SCRIPT_RESOURCE)) {
194            if (in == null) {
195                throw new IOException("Could not find " + SCRIPT_RESOURCE);
196            }
197            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
198        }
199    }
200
201    static Map<String, String> trainingEnvironment(String[] imageEnvironment, JavaRuntime java, TrainingRun run,
202                                                  List<String> trainingPaths) {
203        var environment = new LinkedHashMap<String, String>();
204        var options = new ArrayList<String>();
205        imageVariable(imageEnvironment, JDK_JAVA_OPTIONS).ifPresent(options::add);
206        options.add("-XX:AOTCacheOutput=" + CONTAINER_CACHE_FILE);
207        options.add(NO_PERF_DATA);
208        options.addAll(run.systemProperties(trainingPaths));
209        environment.put(JDK_JAVA_OPTIONS, String.join(" ", options));
210        if (java.compatibleOopCompression()) {
211            // JDK 27 and later: lets the cache work with another compressed oops encoding than the training one
212            var aotOptions = new ArrayList<String>();
213            imageVariable(imageEnvironment, JDK_AOT_VM_OPTIONS).ifPresent(aotOptions::add);
214            aotOptions.add("-XX:+UnlockDiagnosticVMOptions");
215            aotOptions.add("-XX:+" + COMPATIBLE_OOP_COMPRESSION_FLAG);
216            environment.put(JDK_AOT_VM_OPTIONS, String.join(" ", aotOptions));
217        }
218        return environment;
219    }
220
221    static Optional<Integer> firstTcpPort(ExposedPort[] exposedPorts) {
222        if (exposedPorts == null) {
223            return Optional.empty();
224        }
225        return Arrays.stream(exposedPorts)
226            .filter(Objects::nonNull)
227            .filter(port -> port.getProtocol() == null || port.getProtocol() == InternetProtocol.TCP)
228            .map(ExposedPort::getPort)
229            .findFirst();
230    }
231
232    private static Optional<String> imageVariable(String[] imageEnvironment, String name) {
233        if (imageEnvironment == null) {
234            return Optional.empty();
235        }
236        String prefix = name + "=";
237        return Arrays.stream(imageEnvironment)
238            .filter(variable -> variable != null && variable.startsWith(prefix))
239            .map(variable -> variable.substring(prefix.length()))
240            .filter(value -> !value.isBlank())
241            .reduce((first, second) -> second);
242    }
243
244    private JavaRuntime probeJava(String imageId) throws MojoExecutionException {
245        DockerService.ContainerOutput output;
246        try {
247            output = dockerService.runAndCaptureOutput(imageId, timeoutSeconds,
248                List.of("java", "-XX:+UnlockDiagnosticVMOptions", "-XX:+PrintFlagsFinal", "-version"));
249        } catch (IOException e) {
250            throw new MojoExecutionException("JDK AOT cache training failed: could not run java -version in the training image: "
251                + e.getMessage(), e);
252        }
253        Optional<JavaRuntime> java = JavaRuntime.parse(output.output());
254        if (output.exitCode() != 0 || java.isEmpty()) {
255            throw new MojoExecutionException("JDK AOT cache training failed: java -version exited with status "
256                + output.exitCode() + " in the training image: " + lastLines(output.output()));
257        }
258        if (java.get().majorVersion() < MINIMUM_JAVA_VERSION) {
259            throw new MojoExecutionException("micronaut.docker.jdkAotCache needs Java " + MINIMUM_JAVA_VERSION
260                + " or later in the image, but the base image runs Java " + java.get().majorVersion()
261                + ". Use a base image with Java " + MINIMUM_JAVA_VERSION + " or later.");
262        }
263        log.info("JDK AOT cache: the training image runs Java " + java.get().majorVersion());
264        return java.get();
265    }
266
267    private int httpPort(InspectImageResponse image) throws MojoExecutionException {
268        return firstTcpPort(image.getConfig() == null ? null : image.getConfig().getExposedPorts())
269            .orElseThrow(() -> new MojoExecutionException("micronaut.docker.jdkAotCache needs the HTTP port of the "
270                + "application to warm it up, but the image exposes no port because the server port is resolved "
271                + "dynamically. Expose the port with the Jib container.ports configuration."));
272    }
273
274    private void warmUp(String containerId, int port) throws IOException, MojoExecutionException {
275        var command = new ArrayList<>(List.of("bash", "-c", readScript(), "training.sh", "warm-up",
276            String.valueOf(port), String.valueOf(timeoutSeconds)));
277        command.addAll(trainingPaths);
278        var output = new ArrayList<String>();
279        int exitCode = dockerService.execInContainer(containerId, (trainingPaths.size() + 1) * timeoutSeconds, line -> {
280            output.add(line);
281            log.info(line);
282        }, command.toArray(String[]::new));
283        if (exitCode != 0) {
284            dockerService.logContainerOutput(containerId);
285            if (exitCode == COMMAND_NOT_EXECUTABLE || exitCode == COMMAND_NOT_FOUND) {
286                throw new MojoExecutionException("JDK AOT cache training failed: the warm-up needs bash in the image, "
287                    + "which it could not run (status " + exitCode + "). Use a base image with bash, or a Micronaut "
288                    + "version with the training-run switch.");
289            }
290            throw new MojoExecutionException("JDK AOT cache training failed: "
291                + (output.isEmpty() ? "the warm-up exited with status " + exitCode : output.get(output.size() - 1)));
292        }
293    }
294
295    private void copyCache(String containerId, Path cacheFile) throws IOException, MojoExecutionException {
296        try {
297            dockerService.copyFileFromContainer(containerId, CONTAINER_CACHE_FILE, cacheFile);
298        } catch (IOException e) {
299            dockerService.logContainerOutput(containerId);
300            throw new MojoExecutionException("JDK AOT cache training failed: the training run did not write the cache ("
301                + e.getMessage() + ")", e);
302        }
303        long size = Files.size(cacheFile);
304        if (size == 0) {
305            dockerService.logContainerOutput(containerId);
306            throw new MojoExecutionException("JDK AOT cache training failed: the training run wrote an empty cache");
307        }
308        // Decimal megabytes, as Docker gives the size of the image layer that holds the cache
309        log.info("JDK AOT cache: trained " + cacheFile + " (" + String.format(Locale.ROOT, "%.1f MB", size / 1e6)
310            + ")");
311    }
312
313    private static String lastLines(String output) {
314        List<String> lines = output.lines().filter(line -> !line.isBlank()).toList();
315        return String.join(System.lineSeparator(), lines.subList(Math.max(0, lines.size() - 3), lines.size()));
316    }
317
318    /**
319     * The Java runtime of the training image.
320     *
321     * @param majorVersion the Java feature version
322     * @param compatibleOopCompression whether the JVM has the {@code AOTCompatibleOopCompression} diagnostic flag
323     */
324    record JavaRuntime(int majorVersion, boolean compatibleOopCompression) {
325
326        /**
327         * @param output the output of {@code java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version}
328         * @return the Java runtime, if the output has a version line with a valid version number
329         */
330        static Optional<JavaRuntime> parse(String output) {
331            Matcher matcher = JAVA_VERSION.matcher(output);
332            if (!matcher.find()) {
333                return Optional.empty();
334            }
335            try {
336                int major = Integer.parseInt(matcher.group(1));
337                if (major == 1 && matcher.group(2) != null) {
338                    major = Integer.parseInt(matcher.group(2));
339                }
340                return Optional.of(new JavaRuntime(major, COMPATIBLE_OOP_COMPRESSION.matcher(output).find()));
341            } catch (NumberFormatException _) {
342                // a version number too large for an int
343                return Optional.empty();
344            }
345        }
346    }
347}