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.MicronautRuntime;
019import io.micronaut.maven.core.MojoUtils;
020import io.micronaut.maven.jib.JibConfigurationService;
021import io.micronaut.maven.jib.JibMicronautExtension;
022import io.micronaut.maven.services.ApplicationConfigurationService;
023import io.micronaut.maven.services.DockerService;
024import io.micronaut.maven.services.ExecutorService;
025import org.apache.maven.execution.MavenSession;
026import org.apache.maven.plugin.MojoExecution;
027import org.apache.maven.plugin.MojoExecutionException;
028import org.apache.maven.plugins.annotations.Execute;
029import org.apache.maven.plugins.annotations.LifecyclePhase;
030import org.apache.maven.plugins.annotations.Mojo;
031import org.apache.maven.plugins.annotations.ResolutionScope;
032import org.apache.maven.project.MavenProject;
033import org.apache.maven.shared.invoker.MavenInvocationException;
034import org.graalvm.buildtools.utils.NativeImageUtils;
035
036import javax.inject.Inject;
037import java.io.File;
038import java.io.IOException;
039import java.nio.file.Files;
040import java.nio.file.Path;
041import java.nio.file.Paths;
042import java.util.ArrayList;
043import java.util.List;
044import java.util.Optional;
045import java.util.stream.Collectors;
046import java.util.stream.Stream;
047
048import static io.micronaut.maven.DockerNativeMojo.ARGS_FILE_PROPERTY_NAME;
049
050/**
051 * <p>Generates a <code>Dockerfile</code> depending on the <code>packaging</code> and <code>micronaut.runtime</code>
052 * properties.
053 *
054 * <pre>mvn mn:dockerfile -Dpackaging=docker-native -Dmicronaut.runtime=lambda</pre>
055 *
056 * @author Álvaro Sánchez-Mariscal
057 * @since 1.1
058 */
059@Mojo(name = "dockerfile", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
060@Execute(phase = LifecyclePhase.PROCESS_CLASSES)
061public class DockerfileMojo extends AbstractDockerMojo {
062    public static final String DOCKERFILE = "Dockerfile";
063    public static final String DOCKERFILE_AWS_CUSTOM_RUNTIME = "DockerfileNativeLambda";
064    public static final String DOCKERFILE_AWS = "DockerfileLambda";
065    public static final String DOCKERFILE_ORACLE_CLOUD = "DockerfileOracleCloud";
066    public static final String DOCKERFILE_NATIVE = "DockerfileNative";
067    public static final String DOCKERFILE_CRAC = "DockerfileCrac";
068    public static final String DOCKERFILE_CRAC_CHECKPOINT = "DockerfileCracCheckpoint";
069    public static final String DOCKERFILE_CRAC_CHECKPOINT_FILE = "Dockerfile.crac.checkpoint";
070    public static final String DOCKERFILE_NATIVE_DISTROLESS = "DockerfileNativeDistroless";
071    public static final String DOCKERFILE_NATIVE_STATIC = "DockerfileNativeStatic";
072    public static final String DOCKERFILE_NATIVE_ORACLE_CLOUD = "DockerfileNativeOracleCloud";
073    public static final String NATIVE_BUILD_TOOLS_MAVEN_PLUGIN = "org.graalvm.buildtools:native-maven-plugin";
074    private static final String CLASS_NAME_PLACEHOLDER = "CLASS_NAME";
075    private static final String EXEC_MAIN_CLASS_SOURCE = "exec.mainClass";
076
077    private final ExecutorService executorService;
078
079    @Inject
080    public DockerfileMojo(MavenProject mavenProject, DockerService dockerService, JibConfigurationService jibConfigurationService,
081                          ApplicationConfigurationService applicationConfigurationService, ExecutorService executorService,
082                          MavenSession mavenSession, MojoExecution mojoExecution) {
083        super(mavenProject, jibConfigurationService, applicationConfigurationService, dockerService, mavenSession, mojoExecution);
084        this.executorService = executorService;
085    }
086
087    @Override
088    public void execute() throws MojoExecutionException {
089        var runtime = MicronautRuntime.valueOf(micronautRuntime.toUpperCase());
090        var packaging = Packaging.of(mavenProject.getPackaging());
091        try {
092            copyDependencies();
093            var dockerfile = switch (packaging) {
094                case DOCKER_NATIVE -> buildDockerfileNative(runtime);
095                case DOCKER -> buildDockerfile(runtime);
096                case DOCKER_CRAC -> buildCracDockerfile(runtime);
097                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() + "]");
098            };
099
100            dockerfile.ifPresent(file -> getLog().info("Dockerfile written to: " + file.getAbsolutePath()));
101
102        } catch (IOException | MavenInvocationException e) {
103            throw new MojoExecutionException(e.getMessage(), e);
104        }
105    }
106
107    private Optional<File> buildDockerfile(MicronautRuntime runtime) throws IOException, MojoExecutionException {
108        File dockerfile;
109        switch (runtime.getBuildStrategy()) {
110            case ORACLE_FUNCTION -> {
111                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_ORACLE_CLOUD);
112                oracleCloudFunctionCmd(dockerfile);
113                processOracleFunctionDockerfile(dockerfile);
114            }
115            case LAMBDA -> {
116                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_AWS);
117                processDockerfile(dockerfile);
118            }
119            case DEFAULT -> {
120                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE);
121                processDockerfile(dockerfile);
122            }
123            default -> throw new IllegalStateException("Unexpected value: " + runtime.getBuildStrategy());
124        }
125        return Optional.ofNullable(dockerfile);
126    }
127
128    private Optional<File> buildCracDockerfile(MicronautRuntime runtime) throws IOException, MojoExecutionException {
129        File dockerfile;
130        switch (runtime.getBuildStrategy()) {
131            case ORACLE_FUNCTION -> throw new MojoExecutionException("Oracle Functions are currently unsupported");
132            case LAMBDA -> throw new MojoExecutionException("Lambda Functions are currently unsupported");
133            case DEFAULT -> {
134                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_CRAC_CHECKPOINT, DOCKERFILE_CRAC_CHECKPOINT_FILE);
135                processDockerfile(dockerfile);
136                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_CRAC);
137                processDockerfile(dockerfile);
138            }
139            default -> throw new IllegalStateException("Unexpected value: " + runtime.getBuildStrategy());
140        }
141        return Optional.ofNullable(dockerfile);
142    }
143
144    static void processOracleFunctionDockerfile(File dockerfile) throws IOException {
145        if (dockerfile != null) {
146            var allLines = Files.readAllLines(dockerfile.toPath());
147            String projectFnVersion = JibMicronautExtension.determineProjectFnVersion(System.getProperty("java.version"));
148            allLines.add(0, allLines.remove(0) + projectFnVersion);
149            String entrypoint = JibMicronautExtension.buildProjectFnEntrypoint()
150                .stream()
151                .map(s -> "\"" + s + "\"")
152                .collect(Collectors.joining(", "));
153
154            allLines.add("ENTRYPOINT [" + entrypoint + "]");
155
156            Files.write(dockerfile.toPath(), allLines);
157        }
158    }
159
160    private Optional<File> buildDockerfileNative(MicronautRuntime runtime) throws IOException, MavenInvocationException, MojoExecutionException {
161        getLog().info("Generating GraalVM args file");
162        executorService.invokeGoal(NATIVE_BUILD_TOOLS_MAVEN_PLUGIN, "write-args-file");
163        File dockerfile;
164        switch (runtime.getBuildStrategy()) {
165            case LAMBDA -> {
166                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_AWS_CUSTOM_RUNTIME);
167                lambdaBootstrapCommand(dockerfile);
168            }
169            case ORACLE_FUNCTION -> {
170                dockerfile = dockerService.loadDockerfileAsResource(DOCKERFILE_NATIVE_ORACLE_CLOUD);
171                oracleCloudFunctionCmd(dockerfile);
172            }
173            case DEFAULT -> {
174                String dockerfileName = DOCKERFILE_NATIVE;
175                if (Boolean.TRUE.equals(staticNativeImage)) {
176                    getLog().info("Generating a static native image");
177                    dockerfileName = DockerfileMojo.DOCKERFILE_NATIVE_STATIC;
178                } else if (baseImageRun.contains("distroless")) {
179                    getLog().info("Generating a mostly static native image");
180                    dockerfileName = DockerfileMojo.DOCKERFILE_NATIVE_DISTROLESS;
181                }
182                dockerfile = dockerService.loadDockerfileAsResource(dockerfileName);
183            }
184            default -> throw new IllegalStateException("Unexpected value: " + runtime.getBuildStrategy());
185        }
186        processDockerfile(dockerfile);
187        return Optional.ofNullable(dockerfile);
188    }
189
190    private void processDockerfile(File dockerfile) throws IOException, MojoExecutionException {
191        if (dockerfile == null) {
192            return;
193        }
194
195        var allLines = Files.readAllLines(dockerfile.toPath());
196        Files.write(dockerfile.toPath(), processDockerfileLines(allLines));
197
198        String argsFile = findArgsFile();
199        if (argsFile != null) {
200            processNativeImageArgs(argsFile);
201        }
202    }
203
204    private List<String> processDockerfileLines(List<String> allLines) throws MojoExecutionException {
205        var result = new ArrayList<String>();
206        for (String line : allLines) {
207            String processedLine = processDockerfileLine(line);
208            if (processedLine != null) {
209                result.add(processedLine);
210            }
211        }
212        return result;
213    }
214
215    private String processDockerfileLine(String line) throws MojoExecutionException {
216        if (line.startsWith("ARG")) {
217            return shouldInlineArgLine(line) ? null : line;
218        }
219        if (containsPlaceholder(line, "BASE_IMAGE_RUN")) {
220            return line.replace("${BASE_IMAGE_RUN}", validateImageReference("micronaut.native-image.base-image-run", baseImageRun));
221        }
222        if (containsPlaceholder(line, "BASE_IMAGE")) {
223            return line.replace("${BASE_IMAGE}", validateImageReference("jib.from.image", getFrom()));
224        }
225        if (containsPlaceholder(line, "BASE_JAVA_IMAGE")) {
226            return line.replace("${BASE_JAVA_IMAGE}", validateImageReference("base Java image", getBaseImage()));
227        }
228        if (containsPlaceholder(line, "GRAALVM_DOWNLOAD_URL")) {
229            return line.replace("${GRAALVM_DOWNLOAD_URL}", shellLiteral("GraalVM download URL", validateDownloadUrl("GraalVM download URL", graalVmDownloadUrl())));
230        }
231        if (containsPlaceholder(line, CLASS_NAME_PLACEHOLDER)) {
232            return replaceClassName(line);
233        }
234        if (containsPlaceholder(line, "PORTS")) {
235            return line.replace("${PORTS}", validateExposedPorts("jib.container.ports", getPorts()));
236        }
237        return line;
238    }
239
240    private static boolean containsPlaceholder(String line, String placeholderName) {
241        return line.contains("${" + placeholderName + "}");
242    }
243
244    private static boolean shouldInlineArgLine(String line) {
245        String trimmed = line.trim();
246        if (!trimmed.startsWith("ARG")) {
247            return false;
248        }
249        String remainder = trimmed.substring(3).trim();
250        if (remainder.isEmpty()) {
251            return false;
252        }
253
254        int endEquals = remainder.indexOf('=');
255        int endWhitespace = -1;
256        for (int i = 0; i < remainder.length(); i++) {
257            if (Character.isWhitespace(remainder.charAt(i))) {
258                endWhitespace = i;
259                break;
260            }
261        }
262
263        int end = remainder.length();
264        if (endEquals >= 0) {
265            end = Math.min(end, endEquals);
266        }
267        if (endWhitespace >= 0) {
268            end = Math.min(end, endWhitespace);
269        }
270
271        String argName = remainder.substring(0, end);
272        return "BASE_IMAGE_RUN".equals(argName)
273            || "BASE_JAVA_IMAGE".equals(argName)
274            || "BASE_IMAGE".equals(argName)
275            || "GRAALVM_DOWNLOAD_URL".equals(argName)
276            || CLASS_NAME_PLACEHOLDER.equals(argName)
277            || "PORTS".equals(argName);
278    }
279
280    private String replaceClassName(String line) throws MojoExecutionException {
281        String className = isJsonArrayClassNameContext(line)
282            ? escapeJsonString(EXEC_MAIN_CLASS_SOURCE, mainClass)
283            : line.contains("\"${CLASS_NAME}\"")
284                ? escapeShellDoubleQuoted(EXEC_MAIN_CLASS_SOURCE, mainClass)
285            : shellLiteral(EXEC_MAIN_CLASS_SOURCE, mainClass);
286        return line.replace("${CLASS_NAME}", className);
287    }
288
289    private static boolean isJsonArrayClassNameContext(String line) {
290        return containsPlaceholder(line, CLASS_NAME_PLACEHOLDER)
291            && (line.contains("ENTRYPOINT [") || line.contains("CMD ["));
292    }
293
294    private String findArgsFile() throws IOException {
295        String argsFile = mavenProject.getProperties().getProperty(ARGS_FILE_PROPERTY_NAME);
296        if (argsFile != null) {
297            return argsFile;
298        }
299        Path targetPath = Paths.get(mavenProject.getBuild().getDirectory());
300        try (Stream<Path> listStream = Files.list(targetPath)) {
301            return listStream
302                .filter(path -> {
303                    String fileName = path.getFileName().toString();
304                    return fileName.startsWith("native-image") && fileName.endsWith("args");
305                })
306                .findFirst()
307                .map(path -> path.toAbsolutePath().toString())
308                .orElse(null);
309        }
310    }
311
312    private void processNativeImageArgs(String argsFile) throws IOException, MojoExecutionException {
313        List<String> allNativeImageBuildArgs = MojoUtils.computeNativeImageArgs(nativeImageBuildArgs, baseImageRun, argsFile, supportsSharedArena());
314        //Remove extra main class argument
315        allNativeImageBuildArgs.remove(mainClass);
316        getLog().info("GraalVM native image build args: " + allNativeImageBuildArgs);
317        List<String> conversionResult = NativeImageUtils.convertToArgsFile(allNativeImageBuildArgs, Paths.get(mavenProject.getBuild().getDirectory()));
318        if (conversionResult.size() == 1) {
319            Files.delete(Paths.get(argsFile));
320        }
321    }
322}