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.jib;
017
018import com.google.cloud.tools.jib.api.buildplan.AbsoluteUnixPath;
019import com.google.cloud.tools.jib.api.buildplan.ContainerBuildPlan;
020import com.google.cloud.tools.jib.api.buildplan.FileEntriesLayer;
021import com.google.cloud.tools.jib.api.buildplan.FileEntry;
022import com.google.cloud.tools.jib.api.buildplan.LayerObject;
023import com.google.cloud.tools.jib.api.buildplan.Platform;
024import com.google.cloud.tools.jib.api.buildplan.Port;
025import com.google.cloud.tools.jib.buildplan.UnixPathParser;
026import com.google.cloud.tools.jib.maven.extension.JibMavenPluginExtension;
027import com.google.cloud.tools.jib.maven.extension.MavenData;
028import com.google.cloud.tools.jib.plugins.extension.ExtensionLogger;
029import com.google.cloud.tools.jib.plugins.extension.JibPluginExtensionException;
030import io.micronaut.core.util.StringUtils;
031import io.micronaut.maven.core.DockerBuildStrategy;
032import io.micronaut.maven.core.MicronautRuntime;
033import io.micronaut.maven.services.ApplicationConfigurationService;
034import org.apache.maven.execution.MavenSession;
035import org.apache.maven.plugin.MojoExecution;
036import org.apache.maven.plugin.PluginParameterExpressionEvaluator;
037import org.apache.maven.plugin.descriptor.MojoDescriptor;
038import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException;
039import org.slf4j.Logger;
040import org.slf4j.LoggerFactory;
041
042import java.util.ArrayList;
043import java.util.Collections;
044import java.util.List;
045import java.util.Map;
046import java.util.Objects;
047import java.util.Optional;
048
049/**
050 * Jib extension to support building Docker images.
051 *
052 * @author Álvaro Sánchez-Mariscal
053 * @since 1.1
054 */
055public class JibMicronautExtension implements JibMavenPluginExtension<Void> {
056
057    private static final int SUPPORTED_JDK_VERSION = 25;
058    public static final String DEFAULT_JAVA25_BASE_IMAGE = "eclipse-temurin:" + SUPPORTED_JDK_VERSION + "-jre";
059    private static final String LATEST_TAG = "latest";
060    private static final String JDK_TARGET_VERSION = "maven.compiler.target";
061    private static final String JDK_RELEASE_VERSION = "maven.compiler.release";
062    private static final String JDK_SOURCE_VERSION = "maven.compiler.source";
063    private static final Logger LOG = LoggerFactory.getLogger(JibMicronautExtension.class);
064    private static final String LINUX = "linux";
065
066    @Override
067    public Optional<Class<Void>> getExtraConfigType() {
068        return Optional.empty();
069    }
070
071    @Override
072    public ContainerBuildPlan extendContainerBuildPlan(ContainerBuildPlan buildPlan, Map<String, String> properties,
073                                                       Optional<Void> extraConfig, MavenData mavenData,
074                                                       ExtensionLogger logger) throws JibPluginExtensionException {
075
076        ContainerBuildPlan.Builder builder = buildPlan.toBuilder();
077        MicronautRuntime runtime = MicronautRuntime.valueOf(mavenData.getMavenProject().getProperties().getProperty(MicronautRuntime.PROPERTY, "none").toUpperCase());
078
079        var jibConfigurationService = new JibConfigurationService(mavenData.getMavenProject());
080
081        String baseImage = buildPlan.getBaseImage();
082        if (StringUtils.isEmpty(buildPlan.getBaseImage())) {
083            baseImage = determineBaseImage(getJdkVersion(mavenData.getMavenSession()), runtime.getBuildStrategy());
084            builder.setBaseImage(baseImage);
085        }
086        logger.log(ExtensionLogger.LogLevel.LIFECYCLE, "Using base image: " + baseImage);
087
088        if (buildPlan.getExposedPorts() == null || buildPlan.getExposedPorts().isEmpty()) {
089            var applicationConfigurationService = new ApplicationConfigurationService(mavenData.getMavenProject());
090            try {
091                int port = Integer.parseInt(applicationConfigurationService.getServerPort());
092                if (port > 0) {
093                    logger.log(ExtensionLogger.LogLevel.LIFECYCLE, "Exposing port: " + port);
094                    builder.addExposedPort(Port.tcp(port));
095                }
096            } catch (NumberFormatException e) {
097                // ignore, can't automatically expose port
098                logger.log(ExtensionLogger.LogLevel.LIFECYCLE, "Dynamically resolved port present. Ensure the port is correctly exposed in the <container> configuration. See https://github.com/GoogleContainerTools/jib/tree/master/jib-maven-plugin#example for an example.");
099            }
100        }
101
102        configurePlatforms(buildPlan, baseImage, builder, runtime, mavenData, logger);
103
104        switch (runtime.getBuildStrategy()) {
105            case ORACLE_FUNCTION -> {
106                var originalLayers = buildPlan.getLayers();
107                builder.setLayers(originalLayers.stream().map(JibMicronautExtension::remapLayer).toList());
108                var cmd = jibConfigurationService.getArgs();
109                if (cmd.isEmpty()) {
110                    cmd = Collections.singletonList("io.micronaut.oraclecloud.function.http.HttpFunction::handleRequest");
111                }
112                builder.setWorkingDirectory(AbsoluteUnixPath.get(jibConfigurationService.getWorkingDirectory().orElse("/function")))
113                    .setEntrypoint(buildProjectFnEntrypoint())
114                    .setCmd(cmd);
115            }
116            case LAMBDA -> {
117                //TODO Leverage AWS Base images:
118                // https://docs.aws.amazon.com/lambda/latest/dg/java-image.html
119                // https://docs.aws.amazon.com/lambda/latest/dg/images-create.html
120                // https://docs.aws.amazon.com/lambda/latest/dg/images-test.html
121                var entrypoint = buildPlan.getEntrypoint();
122                Objects.requireNonNull(entrypoint).set(entrypoint.size() - 1, "io.micronaut.function.aws.runtime.MicronautLambdaRuntime");
123                builder.setEntrypoint(entrypoint);
124            }
125            default -> {
126                //no op
127            }
128        }
129        return builder.build();
130    }
131
132    /**
133     * Builds for the Docker daemon's platform only when the {@code docker} goal trains a JDK AOT cache, and adds the
134     * detected platform otherwise.
135     */
136    private void configurePlatforms(ContainerBuildPlan buildPlan, String baseImage, ContainerBuildPlan.Builder builder,
137                                    MicronautRuntime runtime, MavenData mavenData, ExtensionLogger logger)
138        throws JibPluginExtensionException {
139        Optional<Platform> jdkAotCachePlatform = JdkAotCachePlan.platform(mavenData);
140        if (jdkAotCachePlatform.isPresent()) {
141            if (runtime.getBuildStrategy() != DockerBuildStrategy.DEFAULT) {
142                throw new JibPluginExtensionException(JibMicronautExtension.class,
143                    "micronaut.docker.jdkAotCache only supports the default runtime, not " + runtime.getBuildStrategy());
144            }
145            JdkAotCachePlan.apply(buildPlan, baseImage, builder, jdkAotCachePlatform.get(), mavenData, logger);
146            return;
147        }
148        var detectedPlatform = detectPlatform();
149        if (buildPlan.getPlatforms() == null || buildPlan.getPlatforms().isEmpty() || !buildPlan.getPlatforms().contains(detectedPlatform)) {
150            LOG.info("Adding Detected platform: {}/{}", LINUX, detectedPlatform.getArchitecture());
151            builder.addPlatform(detectedPlatform.getArchitecture(), LINUX);
152        }
153    }
154
155    public static List<String> buildProjectFnEntrypoint() {
156        var entrypoint = new ArrayList<String>(9);
157        entrypoint.add("java");
158        entrypoint.add("-XX:-UsePerfData");
159        entrypoint.add("-XX:+UseSerialGC");
160        entrypoint.add("-Xshare:auto");
161        entrypoint.add("-Djava.awt.headless=true");
162        entrypoint.add("-Djava.library.path=/function/runtime/lib");
163        entrypoint.add("-cp");
164        entrypoint.add("/function/app/classes:/function/app/libs/release/*:/function/app/libs/snapshot/*:/function/app/resources:/function/runtime/*");
165        entrypoint.add("com.fnproject.fn.runtime.EntryPoint");
166        return entrypoint;
167    }
168
169    public static String determineProjectFnVersion(String javaVersion) {
170        int majorVersion = Integer.parseInt(javaVersion.split("\\.")[0]);
171        if (majorVersion >= 25) {
172            return "25-jre";
173        } else {
174            return LATEST_TAG;
175        }
176    }
177
178    public static String determineBaseImage(String jdkVersion, DockerBuildStrategy buildStrategy) {
179        int javaVersion = Integer.parseInt(jdkVersion);
180        return switch (buildStrategy) {
181            case LAMBDA -> "public.ecr.aws/lambda/java:" + javaVersion;
182            default -> {
183                if (javaVersion > SUPPORTED_JDK_VERSION) {
184                    throw new IllegalArgumentException("Unsupported JDK version for Docker base image: " + javaVersion
185                        + ". Maximum supported version is " + SUPPORTED_JDK_VERSION + ".");
186                }
187                yield DEFAULT_JAVA25_BASE_IMAGE;
188            }
189        };
190    }
191
192    public static String getJdkVersion(MavenSession session) {
193        var releaseVersion = getPropertyValue(session, JDK_RELEASE_VERSION);
194        var targetVersion = getPropertyValue(session, JDK_TARGET_VERSION);
195        var sourceVersion = getPropertyValue(session, JDK_SOURCE_VERSION);
196
197        Optional<String> jdkVersionOpt = releaseVersion
198            .or(() -> targetVersion)
199            .or(() -> sourceVersion);
200
201        String jdkVersion = jdkVersionOpt.orElse(String.valueOf(SUPPORTED_JDK_VERSION));
202        String propertySource = releaseVersion.isPresent() ? JDK_RELEASE_VERSION :
203                                targetVersion.isPresent() ? JDK_TARGET_VERSION :
204                                sourceVersion.isPresent() ? JDK_SOURCE_VERSION : "default (" + SUPPORTED_JDK_VERSION + ")";
205
206        LOG.info("Using JDK version {} from {}", jdkVersion, propertySource);
207        return jdkVersion;
208    }
209
210    private static Optional<String> getPropertyValue(MavenSession session, String propertName) {
211        MojoExecution mojoExecution = new MojoExecution(new MojoDescriptor());
212        var evaluator = new PluginParameterExpressionEvaluator(session, mojoExecution);
213        try {
214            return Optional.ofNullable((String) evaluator.evaluate("${" + propertName + "}", String.class));
215        } catch (ExpressionEvaluationException e) {
216            return Optional.empty();
217        }
218    }
219
220    static LayerObject remapLayer(LayerObject layerObject) {
221        var originalLayer = (FileEntriesLayer) layerObject;
222        FileEntriesLayer.Builder builder = FileEntriesLayer.builder().setName(originalLayer.getName());
223        for (FileEntry originalEntry : originalLayer.getEntries()) {
224            builder.addEntry(remapEntry(originalEntry, layerObject.getName()));
225        }
226
227        return builder.build();
228    }
229
230    static FileEntry remapEntry(FileEntry originalEntry, String layerName) {
231        var pathComponents = UnixPathParser.parse(originalEntry.getExtractionPath().toString());
232        AbsoluteUnixPath newPath;
233        if ("dependencies".equals(layerName)) {
234            newPath = AbsoluteUnixPath.get("/function/app/libs/release/" + pathComponents.get(pathComponents.size() - 1));
235        } else if ("snapshot-dependencies".equals(layerName)) {
236            newPath = AbsoluteUnixPath.get("/function/app/libs/snapshot/" + pathComponents.get(pathComponents.size() - 1));
237        } else {
238            //classes or resources
239            newPath = AbsoluteUnixPath.get("/function" + originalEntry.getExtractionPath());
240        }
241
242        return new FileEntry(originalEntry.getSourceFile(), newPath, originalEntry.getPermissions(),
243            originalEntry.getModificationTime(), originalEntry.getOwnership());
244    }
245
246    private Platform detectPlatform() {
247        String osArchitecture = System.getProperty("os.arch");
248        String arch = "aarch64".equals(osArchitecture) || "arm64".equals(osArchitecture) ? "arm64" : "amd64";
249        return new Platform(arch, LINUX);
250    }
251
252}