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 io.micronaut.core.annotation.Internal; 019 020import java.io.IOException; 021import java.io.OutputStream; 022import java.nio.charset.StandardCharsets; 023import java.nio.file.Files; 024import java.nio.file.Path; 025import java.time.LocalDateTime; 026import java.util.List; 027import java.util.Map; 028import java.util.SortedMap; 029import java.util.StringJoiner; 030import java.util.TreeMap; 031import java.util.jar.Attributes; 032import java.util.jar.JarFile; 033import java.util.jar.JarOutputStream; 034import java.util.jar.Manifest; 035import java.util.stream.Stream; 036import java.util.zip.ZipEntry; 037 038/** 039 * Writes the files that a Dockerfile generated with {@code micronaut.docker.jdkAotCache} copies from the build 040 * context: an application JAR, the class path argument file and the training script. A JDK AOT cache cannot record 041 * classes from a non-empty directory on the class path, so the classes go into a JAR, listed after the dependencies, 042 * as the classes directory is in the default Dockerfile. The dependencies are listed one by one, in the order of the 043 * Maven class path, because the JDK leaves the expansion order of {@code *} entries unspecified. 044 * 045 * @author Álvaro Sánchez-Mariscal 046 * @since 5.1.0 047 */ 048@Internal 049public final class JdkAotCacheDockerContext { 050 051 /** 052 * The directory of the build context that holds the files. 053 */ 054 private static final String CONTEXT_DIRECTORY = "jdk-aot-cache"; 055 056 /** 057 * The application JAR. 058 */ 059 private static final String APPLICATION_JAR = "application.jar"; 060 061 /** 062 * The class path argument file, used as {@code -cp @classpath}. 063 */ 064 private static final String CLASSPATH_FILE = "classpath"; 065 066 /** 067 * The training script. 068 */ 069 private static final String TRAINING_SCRIPT = "training.sh"; 070 071 /** 072 * The working directory of the generated Dockerfile. 073 */ 074 private static final String IMAGE_HOME = "/home/app"; 075 076 /** 077 * Where the generated Dockerfile copies the dependency JARs, in their layer directories. 078 */ 079 private static final String IMAGE_LIBS = IMAGE_HOME + "/libs/"; 080 081 /** 082 * The application JAR in the image, last on the class path. 083 */ 084 private static final String IMAGE_APPLICATION_JAR = IMAGE_HOME + "/" + APPLICATION_JAR; 085 086 /** 087 * A fixed timestamp for the JAR entries, independent of the time zone, so that unchanged classes give an identical 088 * JAR and Docker can reuse the cached training layer. 089 */ 090 private static final LocalDateTime ENTRY_TIME = LocalDateTime.of(1980, 2, 1, 0, 0); 091 092 private static final String META_INF = "META-INF/"; 093 094 private JdkAotCacheDockerContext() { 095 } 096 097 /** 098 * Writes the application JAR, the class path argument file and the training script to {@code jdk-aot-cache} in the 099 * build context. 100 * 101 * @param buildDirectory the build context, usually {@code target} 102 * @param classesDirectory the compiled classes and resources 103 * @param dependencies the dependency JARs in class path order, as paths relative to the directory that the generated 104 * Dockerfile copies them to, such as {@code release/foo.jar} 105 * @throws IOException if a file cannot be written 106 */ 107 public static void write(Path buildDirectory, Path classesDirectory, List<String> dependencies) throws IOException { 108 Path context = buildDirectory.resolve(CONTEXT_DIRECTORY); 109 Files.createDirectories(context); 110 writeApplicationJar(classesDirectory, context.resolve(APPLICATION_JAR)); 111 List<String> classpath = Stream.concat(dependencies.stream().map(dependency -> IMAGE_LIBS + dependency), 112 Stream.of(IMAGE_APPLICATION_JAR)).toList(); 113 Files.writeString(context.resolve(CLASSPATH_FILE), "\"" + String.join(":", classpath) + "\"\n", StandardCharsets.UTF_8); 114 Files.writeString(context.resolve(TRAINING_SCRIPT), JdkAotCacheTraining.readScript(), StandardCharsets.UTF_8); 115 } 116 117 /** 118 * Writes the content of a directory into a JAR, in a stable order and with fixed timestamps. The entries are sorted 119 * by name, not by path, because the order of paths depends on the operating system: Windows compares them ignoring 120 * case and with {@code \} as the separator. 121 * 122 * @param classesDirectory the directory 123 * @param jar the JAR to write 124 * @throws IOException if the JAR cannot be written 125 */ 126 static void writeApplicationJar(Path classesDirectory, Path jar) throws IOException { 127 var manifest = new Manifest(); 128 manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); 129 SortedMap<String, Path> entries = new TreeMap<>(); 130 if (Files.isDirectory(classesDirectory)) { 131 try (Stream<Path> walk = Files.walk(classesDirectory)) { 132 walk.filter(path -> !path.equals(classesDirectory)) 133 .forEach(path -> entries.put(entryName(classesDirectory.relativize(path), Files.isDirectory(path)), path)); 134 } 135 } 136 entries.remove(META_INF); 137 entries.remove(JarFile.MANIFEST_NAME); 138 try (OutputStream out = Files.newOutputStream(jar); 139 var jarOut = new JarOutputStream(out)) { 140 writeEntry(jarOut, META_INF, null); 141 jarOut.putNextEntry(entry(JarFile.MANIFEST_NAME)); 142 manifest.write(jarOut); 143 jarOut.closeEntry(); 144 for (Map.Entry<String, Path> named : entries.entrySet()) { 145 writeEntry(jarOut, named.getKey(), named.getValue()); 146 } 147 } 148 } 149 150 /** 151 * Writes a JAR entry: a directory, if the name ends with {@code /}, or the content of a file. 152 */ 153 private static void writeEntry(JarOutputStream jarOut, String name, Path file) throws IOException { 154 jarOut.putNextEntry(entry(name)); 155 if (!name.endsWith("/")) { 156 Files.copy(file, jarOut); 157 } 158 jarOut.closeEntry(); 159 } 160 161 /** 162 * @param relativePath a path relative to the classes directory 163 * @param directory whether the path is a directory 164 * @return the JAR entry name of the path, which always uses {@code /} as the separator, whatever the operating 165 * system, and ends with {@code /} for a directory 166 */ 167 static String entryName(Path relativePath, boolean directory) { 168 var name = new StringJoiner("/", "", directory ? "/" : ""); 169 for (Path element : relativePath) { 170 name.add(element.toString()); 171 } 172 return name.toString(); 173 } 174 175 private static ZipEntry entry(String name) { 176 var entry = new ZipEntry(name); 177 entry.setTimeLocal(ENTRY_TIME); 178 return entry; 179 } 180}