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}