001/*
002 * Copyright 2017-2022 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;
017
018import io.micronaut.maven.core.DockerBuildStrategy;
019import io.micronaut.maven.core.MicronautRuntime;
020import io.micronaut.maven.core.MojoUtils;
021import io.micronaut.maven.jdkaotcache.JdkAotCacheTraining;
022import io.micronaut.maven.jdkaotcache.TrainingMode;
023import io.micronaut.maven.jdkaotcache.TrainingRun;
024import io.micronaut.maven.jib.JibConfigurationService;
025import io.micronaut.maven.jib.JibMicronautExtension;
026import io.micronaut.maven.services.ApplicationConfigurationService;
027import io.micronaut.maven.services.DockerService;
028import io.micronaut.maven.services.ExecutorService;
029import org.apache.maven.execution.MavenSession;
030import org.apache.maven.plugin.MojoExecution;
031import org.apache.maven.plugin.MojoExecutionException;
032import org.apache.maven.plugins.annotations.Execute;
033import org.apache.maven.plugins.annotations.LifecyclePhase;
034import org.apache.maven.plugins.annotations.Mojo;
035import org.apache.maven.plugins.annotations.Parameter;
036import org.apache.maven.plugins.annotations.ResolutionScope;
037import org.apache.maven.project.MavenProject;
038import org.apache.maven.shared.invoker.MavenInvocationException;
039import org.graalvm.buildtools.utils.NativeImageUtils;
040
041import javax.inject.Inject;
042import java.io.File;
043import java.io.IOException;
044import java.nio.file.Files;
045import java.nio.file.Path;
046import java.nio.file.Paths;
047import java.util.ArrayList;
048import java.util.List;
049import java.util.Optional;
050import java.util.stream.Collectors;
051import java.util.stream.Stream;
052
053import static io.micronaut.maven.DockerNativeMojo.ARGS_FILE_PROPERTY_NAME;
054
055/**
056 * <p>Generates a <code>Dockerfile</code> depending on the <code>packaging</code> and <code>micronaut.runtime</code>
057 * properties.
058 *
059 * <pre>mvn mn:dockerfile -Dpackaging=docker-native -Dmicronaut.runtime=lambda</pre>
060 *
061 * @author Álvaro Sánchez-Mariscal
062 * @since 1.1
063 */
064@Mojo(name = "dockerfile", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
065@Execute(phase = LifecyclePhase.PROCESS_CLASSES)
066public class DockerfileMojo extends AbstractDockerMojo {
067    public static final String DOCKERFILE = "Dockerfile";
068    public static final String DOCKERFILE_AWS_CUSTOM_RUNTIME = "DockerfileNativeLambda";
069    public static final String DOCKERFILE_AWS = "DockerfileLambda";
070    public static final String DOCKERFILE_ORACLE_CLOUD = "DockerfileOracleCloud";
071    public static final String DOCKERFILE_NATIVE = "DockerfileNative";
072    public static final String DOCKERFILE_CRAC = "DockerfileCrac";
073    public static final String DOCKERFILE_CRAC_CHECKPOINT = "DockerfileCracCheckpoint";
074    public static final String DOCKERFILE_CRAC_CHECKPOINT_FILE = "Dockerfile.crac.checkpoint";
075    public static final String DOCKERFILE_NATIVE_DISTROLESS = "DockerfileNativeDistroless";
076    public static final String DOCKERFILE_NATIVE_STATIC = "DockerfileNativeStatic";
077    public static final String DOCKERFILE_NATIVE_ORACLE_CLOUD = "DockerfileNativeOracleCloud";
078    public static final String NATIVE_BUILD_TOOLS_MAVEN_PLUGIN = "org.graalvm.buildtools:native-maven-plugin";
079    static final String DOCKERFILE_JDK_AOT_CACHE = "DockerfileJdkAotCache";
080    private static final String CLASS_NAME_PLACEHOLDER = "CLASS_NAME";
081    private static final String JDK_AOT_CACHE_TRAINING_PLACEHOLDER = "JDK_AOT_CACHE_TRAINING";
082    private static final String EXEC_MAIN_CLASS_SOURCE = "exec.mainClass";
083    /**
084     * Where the training script writes the JDK AOT cache. The {@code ENTRYPOINT} of the {@code DockerfileJdkAotCache}
085     * template loads it from there.
086     */
087    private static final String JDK_AOT_CACHE_IMAGE_FILE = "/home/app/app.aot";
088
089    private final ExecutorService executorService;
090    private String jdkAotCacheTrainingArguments = "";
091
092    /**
093     * Whether the generated Dockerfile trains a JDK AOT cache (Java 25 or later) while the image is built, and starts
094     * the application with it so that it starts faster. Only for {@code docker} packaging, and not for the
095     * {@code lambda} or {@code oracle_function} runtimes. The base image needs {@code bash}.
096     *
097     * @since 5.1.0
098     */
099    @Parameter(property = "micronaut.docker.jdkAotCache", defaultValue = "false")
100    private boolean jdkAotCache;
101
102    /**
103     * How the JDK AOT cache is trained. {@code LOAD} loads the application's bean definitions and exits without
104     * starting it, so the training needs no database or other service. {@code START} starts the application, sends
105     * the training requests and stops it, which trains a more complete cache. When not set, the mode is {@code LOAD} if
106     * the application's Micronaut version supports it (Micronaut Core 5.3.0 or later), and {@code START} otherwise.
107     *
108     * @since 5.1.0
109     */
110    @Parameter(property = "micronaut.docker.jdkAotCache.trainingMode")
111    private TrainingMode jdkAotCacheTrainingMode;
112
113    /**
114     * The paths that a {@code START} training run sends a GET request to once the application is ready. A short,
115     * representative warm-up makes a better cache. A request that gets no response, or a status of 500 or more, fails
116     * the build. A status from 400 to 499 is logged as a warning with Micronaut Core 5.3.0 or later, and fails the build
117     * with earlier versions.
118     *
119     * @since 5.1.0
120     */
121    @Parameter(property = "micronaut.docker.jdkAotCache.trainingPaths")
122    private List<String> jdkAotCacheTrainingPaths;
123
124    /**
125     * The timeout, in seconds, of the JDK AOT cache training run. With Micronaut Core before 5.3.0, it applies to each
126     * step: waiting for the application to be ready, each training request, and waiting for the application to exit.
127     *
128     * @since 5.1.0
129     */
130    @Parameter(property = "micronaut.docker.jdkAotCache.trainingTimeout", defaultValue = "180")
131    private int jdkAotCacheTrainingTimeout;
132
133    @Inject
134    public DockerfileMojo(MavenProject mavenProject, DockerService dockerService, JibConfigurationService jibConfigurationService,
135                          ApplicationConfigurationService applicationConfigurationService, ExecutorService executorService,
136                          MavenSession mavenSession, MojoExecution mojoExecution) {
137        super(mavenProject, jibConfigurationService, applicationConfigurationService, dockerService, mavenSession, mojoExecution);
138        this.executorService = executorService;
139    }
140
141    @Override
142    public void execute() throws MojoExecutionException {
143        var runtime = MicronautRuntime.valueOf(micronautRuntime.toUpperCase());
144        var packaging = Packaging.of(mavenProject.getPackaging());
145        try {
146            if (jdkAotCache && packaging != Packaging.DOCKER) {
147                getLog().warn("micronaut.docker.jdkAotCache only applies to " + Packaging.DOCKER.id() + " packaging, ignoring it");
148            }
149            copyDependencies(jdkAotCache);
150            var dockerfile = switch (packaging) {
151                case DOCKER_NATIVE -> buildDockerfileNative(runtime);
152                case DOCKER -> buildDockerfile(runtime);
153                case DOCKER_CRAC -> buildCracDockerfile(runtime);
154                default -> throw new MojoExecutionException("Packaging is set to [" + packaging + "]. To generate a Dockerfile, set the packaging to either [" + Packaging.DOCKER.id() + "] or [" + Packaging.DOCKER_NATIVE.id() + "]");
155            };
156
157            dockerfile.ifPresent(file -> getLog().info("Dockerfile written to: " + file.getAbsolutePath()));
158
159        } catch (IOException | MavenInvocationException e) {
160            throw new MojoExecutionException(e.getMessage(), e);
161        }
162    }
163
164    private Optional<File> buildDockerfile(MicronautRuntime runtime) throws IOException, MojoExecutionException {
165        if (jdkAotCache) {
166            return buildJdkAotCacheDockerfile(runtime);
167        }
168        File dockerfile;
169        switch (runtime.getBuildStrategy()) {
170            case ORACLE_FUNCTION -> {
171                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_ORACLE_CLOUD);
172                oracleCloudFunctionCmd(dockerfile);
173                processOracleFunctionDockerfile(dockerfile);
174            }
175            case LAMBDA -> {
176                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_AWS);
177                processDockerfile(dockerfile);
178            }
179            case DEFAULT -> {
180                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE);
181                processDockerfile(dockerfile);
182            }
183            default -> throw new IllegalStateException("Unexpected value: " + runtime.getBuildStrategy());
184        }
185        return Optional.ofNullable(dockerfile);
186    }
187
188    private Optional<File> buildJdkAotCacheDockerfile(MicronautRuntime runtime) throws IOException, MojoExecutionException {
189        if (runtime.getBuildStrategy() != DockerBuildStrategy.DEFAULT) {
190            throw new MojoExecutionException("micronaut.docker.jdkAotCache only supports the default runtime, not "
191                + micronautRuntime);
192        }
193        List<String> trainingPaths = JdkAotCacheTraining.validateTrainingPaths(jdkAotCacheTrainingPaths);
194        TrainingRun trainingRun = TrainingRun.resolve(jdkAotCacheTrainingMode, trainingPaths, mavenProject.getArtifacts());
195        trainingRun.logGeneratedDockerfileRun(getLog());
196        jdkAotCacheTrainingArguments = jdkAotCacheTrainingArguments(trainingPaths, trainingRun);
197        File dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_JDK_AOT_CACHE);
198        processDockerfile(dockerfile);
199        return Optional.ofNullable(dockerfile);
200    }
201
202    private String jdkAotCacheTrainingArguments(List<String> trainingPaths, TrainingRun trainingRun) throws MojoExecutionException {
203        if (jdkAotCacheTrainingTimeout <= 0) {
204            throw new MojoExecutionException("micronaut.docker.jdkAotCache.trainingTimeout must be positive");
205        }
206        String port = validateExposedPorts("jib.container.ports", getPorts()).trim().split("[\\s/]+")[0];
207        if (!trainingRun.usesSwitch() && port.isEmpty()) {
208            throw new MojoExecutionException("micronaut.docker.jdkAotCache needs the HTTP port of the application to "
209                + "warm it up. Set it with the Jib container.ports configuration.");
210        }
211        var arguments = new ArrayList<String>();
212        arguments.add(JDK_AOT_CACHE_IMAGE_FILE);
213        arguments.add(port.isEmpty() ? "0" : port);
214        arguments.add(String.valueOf(jdkAotCacheTrainingTimeout));
215        arguments.add(trainingRun.scriptArgument());
216        for (String path : trainingPaths) {
217            arguments.add(shellLiteral("micronaut.docker.jdkAotCache.trainingPaths", path));
218        }
219        return String.join(" ", arguments);
220    }
221
222    private Optional<File> buildCracDockerfile(MicronautRuntime runtime) throws IOException, MojoExecutionException {
223        File dockerfile;
224        switch (runtime.getBuildStrategy()) {
225            case ORACLE_FUNCTION -> throw new MojoExecutionException("Oracle Functions are currently unsupported");
226            case LAMBDA -> throw new MojoExecutionException("Lambda Functions are currently unsupported");
227            case DEFAULT -> {
228                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_CRAC_CHECKPOINT, DOCKERFILE_CRAC_CHECKPOINT_FILE);
229                processDockerfile(dockerfile);
230                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_CRAC);
231                processDockerfile(dockerfile);
232            }
233            default -> throw new IllegalStateException("Unexpected value: " + runtime.getBuildStrategy());
234        }
235        return Optional.ofNullable(dockerfile);
236    }
237
238    static void processOracleFunctionDockerfile(File dockerfile) throws IOException {
239        if (dockerfile != null) {
240            var allLines = Files.readAllLines(dockerfile.toPath());
241            String projectFnVersion = JibMicronautExtension.determineProjectFnVersion(System.getProperty("java.version"));
242            allLines.add(0, allLines.remove(0) + projectFnVersion);
243            String entrypoint = JibMicronautExtension.buildProjectFnEntrypoint()
244                .stream()
245                .map(s -> "\"" + s + "\"")
246                .collect(Collectors.joining(", "));
247
248            allLines.add("ENTRYPOINT [" + entrypoint + "]");
249
250            Files.write(dockerfile.toPath(), allLines);
251        }
252    }
253
254    private Optional<File> buildDockerfileNative(MicronautRuntime runtime) throws IOException, MavenInvocationException, MojoExecutionException {
255        getLog().info("Generating GraalVM args file");
256        executorService.invokeGoal(NATIVE_BUILD_TOOLS_MAVEN_PLUGIN, "write-args-file");
257        File dockerfile;
258        switch (runtime.getBuildStrategy()) {
259            case LAMBDA -> {
260                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_AWS_CUSTOM_RUNTIME);
261                lambdaBootstrapCommand(dockerfile);
262            }
263            case ORACLE_FUNCTION -> {
264                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_NATIVE_ORACLE_CLOUD);
265                oracleCloudFunctionCmd(dockerfile);
266            }
267            case DEFAULT -> {
268                String dockerfileName = DOCKERFILE_NATIVE;
269                if (Boolean.TRUE.equals(staticNativeImage)) {
270                    getLog().info("Generating a static native image");
271                    dockerfileName = DockerfileMojo.DOCKERFILE_NATIVE_STATIC;
272                } else if (baseImageRun.contains("distroless")) {
273                    getLog().info("Generating a mostly static native image");
274                    dockerfileName = DockerfileMojo.DOCKERFILE_NATIVE_DISTROLESS;
275                }
276                dockerfile = dockerService.loadDockerfileAsResource(dockerfileName);
277            }
278            default -> throw new IllegalStateException("Unexpected value: " + runtime.getBuildStrategy());
279        }
280        processDockerfile(dockerfile);
281        return Optional.ofNullable(dockerfile);
282    }
283
284    private void processDockerfile(File dockerfile) throws IOException, MojoExecutionException {
285        if (dockerfile == null) {
286            return;
287        }
288
289        var allLines = Files.readAllLines(dockerfile.toPath());
290        Files.write(dockerfile.toPath(), processDockerfileLines(allLines));
291
292        String argsFile = findArgsFile();
293        if (argsFile != null) {
294            processNativeImageArgs(argsFile);
295        }
296    }
297
298    private List<String> processDockerfileLines(List<String> allLines) throws MojoExecutionException {
299        var result = new ArrayList<String>();
300        for (String line : allLines) {
301            String processedLine = processDockerfileLine(line);
302            if (processedLine != null) {
303                result.add(processedLine);
304            }
305        }
306        return result;
307    }
308
309    private String processDockerfileLine(String line) throws MojoExecutionException {
310        if (containsPlaceholder(line, JDK_AOT_CACHE_TRAINING_PLACEHOLDER)) {
311            line = line.replace("${" + JDK_AOT_CACHE_TRAINING_PLACEHOLDER + "}", jdkAotCacheTrainingArguments);
312        }
313        if (line.startsWith("ARG")) {
314            return shouldInlineArgLine(line) ? null : line;
315        }
316        if (containsPlaceholder(line, "BASE_IMAGE_RUN")) {
317            return line.replace("${BASE_IMAGE_RUN}", validateImageReference("micronaut.native-image.base-image-run", baseImageRun));
318        }
319        if (containsPlaceholder(line, "BASE_IMAGE")) {
320            return line.replace("${BASE_IMAGE}", validateImageReference("jib.from.image", getFrom()));
321        }
322        if (containsPlaceholder(line, "BASE_JAVA_IMAGE")) {
323            return line.replace("${BASE_JAVA_IMAGE}", validateImageReference("base Java image", getBaseImage()));
324        }
325        if (containsPlaceholder(line, "GRAALVM_DOWNLOAD_URL")) {
326            return line.replace("${GRAALVM_DOWNLOAD_URL}", shellLiteral("GraalVM download URL", validateDownloadUrl("GraalVM download URL", graalVmDownloadUrl())));
327        }
328        if (containsPlaceholder(line, CLASS_NAME_PLACEHOLDER)) {
329            return replaceClassName(line);
330        }
331        if (containsPlaceholder(line, "PORTS")) {
332            return line.replace("${PORTS}", validateExposedPorts("jib.container.ports", getPorts()));
333        }
334        return line;
335    }
336
337    private static boolean containsPlaceholder(String line, String placeholderName) {
338        return line.contains("${" + placeholderName + "}");
339    }
340
341    private static boolean shouldInlineArgLine(String line) {
342        String trimmed = line.trim();
343        if (!trimmed.startsWith("ARG")) {
344            return false;
345        }
346        String remainder = trimmed.substring(3).trim();
347        if (remainder.isEmpty()) {
348            return false;
349        }
350
351        int endEquals = remainder.indexOf('=');
352        int endWhitespace = -1;
353        for (int i = 0; i < remainder.length(); i++) {
354            if (Character.isWhitespace(remainder.charAt(i))) {
355                endWhitespace = i;
356                break;
357            }
358        }
359
360        int end = remainder.length();
361        if (endEquals >= 0) {
362            end = Math.min(end, endEquals);
363        }
364        if (endWhitespace >= 0) {
365            end = Math.min(end, endWhitespace);
366        }
367
368        String argName = remainder.substring(0, end);
369        return "BASE_IMAGE_RUN".equals(argName)
370            || "BASE_JAVA_IMAGE".equals(argName)
371            || "BASE_IMAGE".equals(argName)
372            || "GRAALVM_DOWNLOAD_URL".equals(argName)
373            || CLASS_NAME_PLACEHOLDER.equals(argName)
374            || "PORTS".equals(argName);
375    }
376
377    private String replaceClassName(String line) throws MojoExecutionException {
378        String className = isJsonArrayClassNameContext(line)
379            ? escapeJsonString(EXEC_MAIN_CLASS_SOURCE, mainClass)
380            : line.contains("\"${CLASS_NAME}\"")
381                ? escapeShellDoubleQuoted(EXEC_MAIN_CLASS_SOURCE, mainClass)
382            : shellLiteral(EXEC_MAIN_CLASS_SOURCE, mainClass);
383        return line.replace("${CLASS_NAME}", className);
384    }
385
386    private static boolean isJsonArrayClassNameContext(String line) {
387        return containsPlaceholder(line, CLASS_NAME_PLACEHOLDER)
388            && (line.contains("ENTRYPOINT [") || line.contains("CMD ["));
389    }
390
391    private String findArgsFile() throws IOException {
392        String argsFile = mavenProject.getProperties().getProperty(ARGS_FILE_PROPERTY_NAME);
393        if (argsFile != null) {
394            return argsFile;
395        }
396        Path targetPath = Paths.get(mavenProject.getBuild().getDirectory());
397        try (Stream<Path> listStream = Files.list(targetPath)) {
398            return listStream
399                .filter(path -> {
400                    String fileName = path.getFileName().toString();
401                    return fileName.startsWith("native-image") && fileName.endsWith("args");
402                })
403                .findFirst()
404                .map(path -> path.toAbsolutePath().toString())
405                .orElse(null);
406        }
407    }
408
409    private void processNativeImageArgs(String argsFile) throws IOException, MojoExecutionException {
410        List<String> allNativeImageBuildArgs = MojoUtils.computeNativeImageArgs(nativeImageBuildArgs, baseImageRun, argsFile, supportsSharedArena());
411        //Remove extra main class argument
412        allNativeImageBuildArgs.remove(mainClass);
413        getLog().info("GraalVM native image build args: " + allNativeImageBuildArgs);
414        List<String> conversionResult = NativeImageUtils.convertToArgsFile(allNativeImageBuildArgs, Paths.get(mavenProject.getBuild().getDirectory()));
415        if (conversionResult.size() == 1) {
416            Files.delete(Paths.get(argsFile));
417        }
418    }
419}