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 com.github.dockerjava.api.command.BuildImageCmd;
019import com.github.dockerjava.api.command.InspectImageResponse;
020import com.github.dockerjava.api.exception.NotFoundException;
021import com.google.cloud.tools.jib.plugins.common.PropertyNames;
022import io.micronaut.maven.core.DockerBuildStrategy;
023import io.micronaut.maven.core.MicronautRuntime;
024import io.micronaut.maven.jdkaotcache.JdkAotCacheTraining;
025import io.micronaut.maven.jdkaotcache.TrainingMode;
026import io.micronaut.maven.jdkaotcache.TrainingRun;
027import io.micronaut.maven.jib.JibConfiguration;
028import io.micronaut.maven.jib.JibConfigurationService;
029import io.micronaut.maven.services.ApplicationConfigurationService;
030import io.micronaut.maven.services.DockerService;
031import io.micronaut.maven.services.ExecutorService;
032import org.apache.maven.execution.MavenSession;
033import org.apache.maven.plugin.MojoExecution;
034import org.apache.maven.plugin.MojoExecutionException;
035import org.apache.maven.plugins.annotations.Mojo;
036import org.apache.maven.plugins.annotations.Parameter;
037import org.apache.maven.plugins.annotations.ResolutionScope;
038import org.apache.maven.project.MavenProject;
039
040import javax.inject.Inject;
041import java.io.File;
042import java.io.IOException;
043import java.nio.file.Files;
044import java.nio.file.LinkOption;
045import java.nio.file.Path;
046import java.nio.file.StandardCopyOption;
047import java.util.HashMap;
048import java.util.List;
049import java.util.Locale;
050import java.util.Map;
051import java.util.Optional;
052import java.util.Properties;
053import java.util.Set;
054
055import static io.micronaut.maven.DockerfileMojo.DOCKERFILE_ORACLE_CLOUD;
056
057/**
058 * <p>Allows using a provided Dockerfile.</p>
059 * <p><strong>WARNING</strong>: this goal is not intended to be executed directly. Instead, specify the packaging type
060 * using the <code>packaging</code> property, eg:</p>
061 *
062 * <pre>mvn package -Dpackaging=docker</pre>
063 *
064 * @author Álvaro Sánchez-Mariscal
065 * @since 1.1
066 */
067@Mojo(name = DockerMojo.DOCKER_PACKAGING, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
068public class DockerMojo extends AbstractDockerMojo {
069
070    public static final String DOCKER_PACKAGING = "docker";
071
072    /**
073     * The {@code os/architecture} of the Docker daemon that trains the JDK AOT cache, for the Jib extension, which reads
074     * it from the project properties in the training build and the final build.
075     */
076    static final String JDK_AOT_CACHE_PLATFORM_PROPERTY = "micronaut.docker.jdkAotCache.internal.platform";
077
078    /**
079     * The trained JDK AOT cache on the build host, for the final Jib build.
080     */
081    static final String JDK_AOT_CACHE_FILE_PROPERTY = "micronaut.docker.jdkAotCache.internal.file";
082
083    /**
084     * Whether the final Jib build pins a registry base image to the digest that the training build pulled.
085     */
086    static final String JDK_AOT_CACHE_PIN_BASE_IMAGE_PROPERTY = "micronaut.docker.jdkAotCache.internal.pinBaseImage";
087
088    private static final String JIB_PLUGIN = "com.google.cloud.tools:jib-maven-plugin";
089    private static final String JAR_PLUGIN = "org.apache.maven.plugins:maven-jar-plugin";
090    private static final String JDK_AOT_CACHE_DIRECTORY = "jdk-aot-cache";
091    private static final String JDK_AOT_CACHE_FILE = "app.aot";
092    private static final String DOCKER_URI_PREFIX = "docker://";
093    private static final String TAR_URI_PREFIX = "tar://";
094    private static final String PACKAGED = "packaged";
095    private static final String AMD64 = "amd64";
096    private static final String ARM64 = "arm64";
097
098    private final ExecutorService executorService;
099
100    /**
101     * Whether to train a JDK AOT cache (Java 25 or later) while the image is built, and start the application with it
102     * so that it starts faster. Only for {@code docker} packaging, and not for the {@code lambda} or
103     * {@code oracle_function} runtimes. The training needs a Docker daemon, and with Jib the {@code container.jvmFlags}
104     * must select a garbage collector, for example {@code -XX:+UseSerialGC}.
105     *
106     * @since 5.1.0
107     */
108    @Parameter(property = "micronaut.docker.jdkAotCache", defaultValue = "false")
109    private boolean jdkAotCache;
110
111    /**
112     * How the JDK AOT cache is trained. {@code LOAD} loads the application's bean definitions and exits without
113     * starting it, so the training needs no database or other service. {@code START} starts the application, sends
114     * the training requests and stops it, which trains a more complete cache. When not set, the mode is {@code LOAD} if
115     * the application's Micronaut version supports it (Micronaut Core 5.3.0 or later), and {@code START} otherwise.
116     *
117     * @since 5.1.0
118     */
119    @Parameter(property = "micronaut.docker.jdkAotCache.trainingMode")
120    private TrainingMode jdkAotCacheTrainingMode;
121
122    /**
123     * The paths that a {@code START} training run sends a GET request to once the application is ready. A short,
124     * representative warm-up makes a better cache. A request that gets no response, or a status of 500 or more, fails
125     * 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
126     * with earlier versions.
127     *
128     * @since 5.1.0
129     */
130    @Parameter(property = "micronaut.docker.jdkAotCache.trainingPaths")
131    private List<String> jdkAotCacheTrainingPaths;
132
133    /**
134     * The timeout, in seconds, of the JDK AOT cache training run. With Micronaut Core before 5.3.0, it applies to each
135     * step: waiting for the application to be ready, each training request, and waiting for the application to exit.
136     *
137     * @since 5.1.0
138     */
139    @Parameter(property = "micronaut.docker.jdkAotCache.trainingTimeout", defaultValue = "180")
140    private int jdkAotCacheTrainingTimeout;
141
142    @SuppressWarnings("CdiInjectionPointsInspection")
143    @Inject
144    public DockerMojo(MavenProject mavenProject, JibConfigurationService jibConfigurationService,
145                      ApplicationConfigurationService applicationConfigurationService, DockerService dockerService,
146                      MavenSession mavenSession, MojoExecution mojoExecution, ExecutorService executorService) {
147        super(mavenProject, jibConfigurationService, applicationConfigurationService, dockerService, mavenSession,
148            mojoExecution);
149        this.executorService = executorService;
150    }
151
152    @Override
153    public void execute() throws MojoExecutionException {
154        var providedDockerfile = new File(mavenProject.getBasedir(), DockerfileMojo.DOCKERFILE);
155        if (jdkAotCache && MicronautRuntime.valueOf(micronautRuntime.toUpperCase()).getBuildStrategy() != DockerBuildStrategy.DEFAULT) {
156            throw new MojoExecutionException("micronaut.docker.jdkAotCache only supports the default runtime, not "
157                + micronautRuntime);
158        }
159        if (shouldBuildWithDockerfile(providedDockerfile)) {
160            var dockerfile = determineDockerfile(providedDockerfile);
161            buildDockerfile(dockerfile, providedDockerfile.exists());
162        } else {
163            validateJibBuildGoal();
164            if (JIB_BUILD_GOAL_BUILD.equals(jibBuildGoal)) {
165                requireConfiguredToImageForJibRegistryBuild();
166            }
167            if (jibConfigurationService.getFromImage().isEmpty()) {
168                mavenProject.getProperties().setProperty(PropertyNames.FROM_IMAGE, getBaseImage());
169            }
170            if (jdkAotCache) {
171                buildWithJdkAotCache();
172            } else {
173                executorService.executeGoal(mavenProject, JIB_PLUGIN, jibBuildGoal);
174            }
175        }
176    }
177
178    /**
179     * Builds a training image with Jib in packaged mode, trains a JDK AOT cache with it and builds the image again with
180     * the configured goal. Jib layers are reproducible, so the JARs of the final image match the trained ones in size
181     * and modification time, which the cache checks, and the extension adds the cache as the last layer.
182     */
183    private void buildWithJdkAotCache() throws MojoExecutionException {
184        List<String> trainingPaths = validateJdkAotCacheConfiguration();
185        TrainingRun trainingRun = TrainingRun.resolve(jdkAotCacheTrainingMode, trainingPaths, mavenProject.getArtifacts());
186        String platform = daemonPlatform();
187        requireDaemonPlatform(platform);
188        trainingRun.log(getLog());
189        // Jib's packaged mode puts the JAR of the maven-jar-plugin in the image, so only a build with the cache needs it
190        getLog().info("JDK AOT cache: building the application JAR for Jib's packaged mode");
191        executorService.executeGoal(mavenProject, JAR_PLUGIN, "jar");
192
193        Path workDirectory = Path.of(mavenProject.getBuild().getDirectory(), JDK_AOT_CACHE_DIRECTORY);
194        Path cacheFile = workDirectory.resolve(JDK_AOT_CACHE_FILE);
195        Properties properties = mavenProject.getProperties();
196        Map<String, String> savedProperties = saveProperties(properties, PropertyNames.CONTAINERIZING_MODE,
197            PropertyNames.TO_IMAGE, JDK_AOT_CACHE_PLATFORM_PROPERTY, JDK_AOT_CACHE_FILE_PROPERTY,
198            JDK_AOT_CACHE_PIN_BASE_IMAGE_PROPERTY);
199        String trainingImage = null;
200        String trainingImageId = null;
201        try {
202            Files.createDirectories(workDirectory);
203            Files.deleteIfExists(cacheFile);
204            properties.setProperty(PropertyNames.CONTAINERIZING_MODE, PACKAGED);
205            properties.setProperty(JDK_AOT_CACHE_PLATFORM_PROPERTY, platform);
206
207            properties.setProperty(PropertyNames.TO_IMAGE, mavenProject.getArtifactId().toLowerCase(Locale.ROOT) + "-jdk-aot-training");
208            trainingImage = targetImage();
209            trainingImageId = buildTrainingImage(trainingImage, platform);
210            restoreProperty(properties, PropertyNames.TO_IMAGE, savedProperties.get(PropertyNames.TO_IMAGE));
211
212            new JdkAotCacheTraining(dockerService, getLog(), trainingPaths, jdkAotCacheTrainingTimeout, networkMode)
213                .train(trainingImageId, trainingRun, cacheFile);
214
215            properties.setProperty(JDK_AOT_CACHE_FILE_PROPERTY, cacheFile.toAbsolutePath().toString());
216            properties.setProperty(JDK_AOT_CACHE_PIN_BASE_IMAGE_PROPERTY, String.valueOf(isRegistryBaseImage()));
217            getLog().info("JDK AOT cache: building the image with the cache (jib:" + jibBuildGoal + ")");
218            executorService.executeGoal(mavenProject, JIB_PLUGIN, jibBuildGoal);
219            if (JIB_BUILD_GOAL_DOCKER_BUILD.equals(jibBuildGoal)) {
220                verifyFinalImageLayers(trainingImageId, targetImage());
221            }
222        } catch (IOException e) {
223            throw new MojoExecutionException("JDK AOT cache: " + e.getMessage(), e);
224        } finally {
225            restoreProperties(properties, savedProperties);
226            if (trainingImageId != null) {
227                removeTrainingImage(trainingImage, trainingImageId);
228            }
229        }
230    }
231
232    /**
233     * @return the ID of the training image
234     */
235    private String buildTrainingImage(String trainingImage, String platform) throws MojoExecutionException {
236        getLog().info("JDK AOT cache: building the training image " + trainingImage + " for " + platform);
237        executorService.executeGoal(mavenProject, JIB_PLUGIN, JIB_BUILD_GOAL_DOCKER_BUILD);
238        return inspectImage(trainingImage)
239            .orElseThrow(() -> new MojoExecutionException("JDK AOT cache: Jib did not build the training image "
240                + trainingImage + ". Is jib.skip set?"))
241            .getId();
242    }
243
244    private List<String> validateJdkAotCacheConfiguration() throws MojoExecutionException {
245        if (!jibConfigurationService.getEntrypoint().isEmpty()) {
246            throw new MojoExecutionException("micronaut.docker.jdkAotCache needs Jib's default entrypoint, which starts "
247                + "java with the application class path. Remove the Jib container.entrypoint configuration.");
248        }
249        String containerizingMode = mavenSession.getUserProperties().getProperty(PropertyNames.CONTAINERIZING_MODE);
250        if (containerizingMode != null && !PACKAGED.equals(containerizingMode)) {
251            throw new MojoExecutionException("micronaut.docker.jdkAotCache needs jib.containerizingMode=" + PACKAGED
252                + ", because a JDK AOT cache cannot use classes from a directory, but it is set to " + containerizingMode);
253        }
254        if (jdkAotCacheTrainingTimeout <= 0) {
255            throw new MojoExecutionException("micronaut.docker.jdkAotCache.trainingTimeout must be positive");
256        }
257        return JdkAotCacheTraining.validateTrainingPaths(jdkAotCacheTrainingPaths);
258    }
259
260    private String daemonPlatform() throws MojoExecutionException {
261        try {
262            return dockerService.getDaemonPlatform();
263        } catch (IllegalStateException e) {
264            throw new MojoExecutionException("micronaut.docker.jdkAotCache runs the application in a Docker container to "
265                + "train the cache, so it needs a Docker daemon, also with jib.buildGoal=buildTar or build. "
266                + e.getMessage(), e);
267        }
268    }
269
270    private void requireDaemonPlatform(String platform) throws MojoExecutionException {
271        Set<JibConfiguration.PlatformConfiguration> platforms = jibConfigurationService.getFromPlatforms();
272        if (platforms.isEmpty()) {
273            return;
274        }
275        List<String> configured = platforms.stream()
276            .map(p -> p.os().orElse("linux") + "/" + goArchitecture(p.architecture().orElse(AMD64)))
277            .distinct()
278            .toList();
279        if (!configured.equals(List.of(platform))) {
280            throw new MojoExecutionException("micronaut.docker.jdkAotCache trains the cache on the Docker daemon, whose "
281                + "platform is " + platform + ", and a cache only works on the platform that trained it. The Jib "
282                + "from.platforms configuration lists " + configured + ": remove it, or set it to " + platform + " only.");
283        }
284    }
285
286    /**
287     * The Go architecture name of an architecture of the Jib {@code from.platforms} configuration, as the platform of
288     * the Docker daemon gives it.
289     */
290    private static String goArchitecture(String architecture) {
291        return switch (architecture) {
292            case "x86_64", AMD64 -> AMD64;
293            case "aarch64", ARM64 -> ARM64;
294            default -> architecture;
295        };
296    }
297
298    private boolean isRegistryBaseImage() {
299        String baseImage = Optional.ofNullable(jibProperty(PropertyNames.FROM_IMAGE))
300            .or(jibConfigurationService::getFromImage)
301            .orElse("");
302        return !baseImage.startsWith(DOCKER_URI_PREFIX) && !baseImage.startsWith(TAR_URI_PREFIX);
303    }
304
305    /**
306     * @return the image that Jib builds, with the precedence of the Jib Maven plugin
307     */
308    private String targetImage() {
309        return Optional.ofNullable(jibProperty(PropertyNames.TO_IMAGE_ALTERNATE))
310            .or(() -> Optional.ofNullable(jibProperty(PropertyNames.TO_IMAGE)))
311            .orElseGet(() -> getTags().iterator().next());
312    }
313
314    /**
315     * Resolves a Jib property as the Jib Maven plugin does: user properties, project properties, then system
316     * properties.
317     */
318    private String jibProperty(String name) {
319        for (Properties properties : List.of(mavenSession.getUserProperties(), mavenProject.getProperties(), mavenSession.getSystemProperties())) {
320            if (properties.containsKey(name)) {
321                return properties.getProperty(name);
322            }
323        }
324        return null;
325    }
326
327    private void verifyFinalImageLayers(String trainingImageId, String finalImage) throws MojoExecutionException {
328        List<String> trainingLayers = inspectImage(trainingImageId).map(DockerMojo::layers).orElse(List.of());
329        List<String> finalLayers = inspectImage(finalImage).map(DockerMojo::layers).orElse(List.of());
330        if (trainingLayers.isEmpty() || finalLayers.isEmpty()) {
331            getLog().warn("JDK AOT cache: could not compare the layers of " + finalImage + " with the training image");
332            return;
333        }
334        if (finalLayers.size() != trainingLayers.size() + 1 || !finalLayers.subList(0, trainingLayers.size()).equals(trainingLayers)) {
335            throw new MojoExecutionException("JDK AOT cache: the layers of " + finalImage + " differ from the layers "
336                + "of the training image, so the cache may not match the image. Did the base image change during the "
337                + "build? Build the image again.");
338        }
339        getLog().info("JDK AOT cache: " + finalImage + " has the " + trainingLayers.size()
340            + " layers of the training image and the cache layer");
341    }
342
343    private Optional<InspectImageResponse> inspectImage(String image) {
344        try {
345            return Optional.of(dockerService.inspectImage(image));
346        } catch (NotFoundException _) {
347            return Optional.empty();
348        }
349    }
350
351    private static List<String> layers(InspectImageResponse image) {
352        return image.getRootFS() == null || image.getRootFS().getLayers() == null ? List.of() : image.getRootFS().getLayers();
353    }
354
355    /**
356     * Removes the tag of the training image, then the training image if no tag refers to it anymore. Jib images are
357     * reproducible, so an image that the project built before in packaged mode can have the ID of the training image,
358     * and its tags stay. When {@code jib.to.image} is a user property, the training image has the tag of the final
359     * image, which the final build moved: the tag is only removed while it refers to the training image.
360     */
361    private void removeTrainingImage(String trainingImage, String trainingImageId) {
362        try {
363            if (inspectImage(trainingImage).filter(image -> trainingImageId.equals(image.getId())).isPresent()) {
364                dockerService.removeImage(trainingImage);
365            }
366            boolean untagged = inspectImage(trainingImageId)
367                .filter(image -> image.getRepoTags() == null || image.getRepoTags().isEmpty())
368                .isPresent();
369            if (untagged) {
370                dockerService.removeImage(trainingImageId);
371            }
372        } catch (RuntimeException e) {
373            getLog().warn("JDK AOT cache: could not remove the training image " + trainingImageId + ": " + e.getMessage());
374        }
375    }
376
377    private static Map<String, String> saveProperties(Properties properties, String... names) {
378        var saved = new HashMap<String, String>();
379        for (String name : names) {
380            saved.put(name, properties.getProperty(name));
381        }
382        return saved;
383    }
384
385    private static void restoreProperties(Properties properties, Map<String, String> saved) {
386        saved.forEach((name, value) -> restoreProperty(properties, name, value));
387    }
388
389    private static void restoreProperty(Properties properties, String name, String value) {
390        if (value == null) {
391            properties.remove(name);
392        } else {
393            properties.setProperty(name, value);
394        }
395    }
396
397    private File determineDockerfile(File providedDockerfile) throws MojoExecutionException {
398        if (providedDockerfile.exists()) {
399            return providedDockerfile;
400        }
401        try {
402            return dockerService.loadDockerfileAsResource(DOCKERFILE_ORACLE_CLOUD);
403        } catch (IOException e) {
404            throw new MojoExecutionException("Error loading Dockerfile", e);
405        }
406    }
407
408    private void buildDockerfile(File dockerfile, boolean providedDockerfileExists) throws MojoExecutionException {
409        try {
410            var runtime = MicronautRuntime.valueOf(micronautRuntime.toUpperCase());
411            if (runtime.getBuildStrategy() == DockerBuildStrategy.ORACLE_FUNCTION && !providedDockerfileExists) {
412                oracleCloudFunctionCmd(dockerfile);
413                DockerfileMojo.processOracleFunctionDockerfile(dockerfile);
414            }
415            getLog().info("Using Dockerfile: " + dockerfile.getAbsolutePath());
416            mavenProject.getProperties().put(PropertyNames.SKIP, "true");
417
418            copyDependencies(jdkAotCache);
419
420            String targetDir = mavenProject.getBuild().getDirectory();
421            var targetDockerfile = new File(targetDir, dockerfile.getName());
422            Files.copy(dockerfile.toPath(), targetDockerfile.toPath(), LinkOption.NOFOLLOW_LINKS,
423                StandardCopyOption.REPLACE_EXISTING);
424
425            BuildImageCmd buildImageCmd = dockerService.buildImageCmd()
426                .withDockerfile(targetDockerfile)
427                .withTags(getTags())
428                .withBaseDirectory(new File(targetDir));
429            getNetworkMode().ifPresent(buildImageCmd::withNetworkMode);
430            dockerService.buildImage(buildImageCmd);
431        } catch (IOException e) {
432            throw new MojoExecutionException(e.getMessage(), e);
433        }
434    }
435
436}