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}