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.google.common.io.FileWriteMode;
019import io.micronaut.core.util.StringUtils;
020import io.micronaut.maven.core.MicronautRuntime;
021import io.micronaut.maven.jib.JibConfigurationService;
022import io.micronaut.maven.jib.JibMicronautExtension;
023import io.micronaut.maven.services.ApplicationConfigurationService;
024import io.micronaut.maven.services.DockerService;
025import org.apache.maven.artifact.Artifact;
026import org.apache.maven.artifact.versioning.ArtifactVersion;
027import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
028import org.apache.maven.execution.MavenSession;
029import org.apache.maven.plugin.MojoExecution;
030import org.apache.maven.plugin.PluginParameterExpressionEvaluator;
031import org.apache.maven.plugins.annotations.Parameter;
032import org.apache.maven.project.MavenProject;
033
034import java.io.File;
035import java.io.IOException;
036import java.nio.charset.Charset;
037import java.nio.file.Files;
038import java.nio.file.StandardCopyOption;
039import java.util.Arrays;
040import java.util.HashSet;
041import java.util.List;
042import java.util.Map;
043import java.util.NavigableSet;
044import java.util.Optional;
045import java.util.Set;
046import java.util.TreeSet;
047import java.util.stream.Collectors;
048
049import static io.micronaut.maven.services.ApplicationConfigurationService.DEFAULT_PORT;
050
051/**
052 * Abstract base class for mojos related to Docker files and builds.
053 *
054 * @author Álvaro Sánchez-Mariscal
055 * @author Iván López
056 * @since 1.1
057 */
058public abstract class AbstractDockerMojo extends AbstractMicronautMojo {
059
060    public static final String LATEST_TAG = "latest";
061    // GlibC 2.34 is used by native image 17
062    public static final String DEFAULT_BASE_IMAGE_GRAALVM_RUN = "cgr.dev/chainguard/wolfi-base:latest";
063    public static final String MOSTLY_STATIC_NATIVE_IMAGE_GRAALVM_FLAG = "-H:+StaticExecutableWithDynamicLibC";
064    public static final String ARM_ARCH = "aarch64";
065    public static final String X86_64_ARCH = "x64";
066    public static final String ORACLE_CLOUD_FUNCTION_DEFAULT_CMD = "CMD [\"io.micronaut.oraclecloud.function.http.HttpFunction::handleRequest\"]";
067    public static final String GDS_DOWNLOAD_URL = "https://gds.oracle.com/download/graal/%s/latest-gftc/graalvm-jdk-%s_linux-%s_bin.tar.gz";
068    private static final NavigableSet<Integer> GRAALVM_VERSIONS = new TreeSet<>(Set.of(21, 25));
069
070    protected final MavenProject mavenProject;
071    protected final MavenSession mavenSession;
072    protected final JibConfigurationService jibConfigurationService;
073    protected final ApplicationConfigurationService applicationConfigurationService;
074    protected final DockerService dockerService;
075    protected final PluginParameterExpressionEvaluator expressionEvaluator;
076
077
078    /**
079     * Additional arguments that will be passed to the <code>native-image</code> executable. Note that this will only
080     * be used when using a packaging of type <code>docker-native</code>. For <code>native-image</code> packaging
081     * you should use the
082     * <a href="https://www.graalvm.org/reference-manual/native-image/NativeImageMavenPlugin/#maven-plugin-customization">
083     * Native Image Maven Plugin
084     * </a> configuration options.
085     */
086    @Parameter(property = "micronaut.native-image.args")
087    protected List<String> nativeImageBuildArgs;
088
089    /**
090     * List of additional arguments that will be passed to the application.
091     */
092    @Parameter(property = RunMojo.MN_APP_ARGS)
093    protected List<String> appArguments;
094
095    /**
096     * The main class of the application, as defined in the
097     * <a href="https://www.mojohaus.org/exec-maven-plugin/java-mojo.html#mainClass">Exec Maven Plugin</a>.
098     */
099    @Parameter(defaultValue = RunMojo.EXEC_MAIN_CLASS, required = true)
100    protected String mainClass;
101
102    /**
103     * Whether to produce a static native image when using <code>docker-native</code> packaging.
104     */
105    @Parameter(defaultValue = "false", property = "micronaut.native-image.static")
106    protected Boolean staticNativeImage;
107
108    /**
109     * The target runtime of the application.
110     */
111    @Parameter(property = MicronautRuntime.PROPERTY, defaultValue = "NONE")
112    protected String micronautRuntime;
113
114    /**
115     * The Docker image used to run the native image.
116     *
117     * @since 1.2
118     */
119    @Parameter(property = "micronaut.native-image.base-image-run", defaultValue = DEFAULT_BASE_IMAGE_GRAALVM_RUN)
120    protected String baseImageRun;
121
122    /**
123     * The version of Oracle Linux to use as a native-compile base when building a native image inside a Docker container.
124     */
125    @Parameter(property = "micronaut.native-image.ol.version")
126    protected String oracleLinuxVersion;
127
128    /**
129     * Networking mode for the RUN instructions during build.
130     *
131     * @since 4.0.0
132     */
133    @Parameter(property = "docker.networkMode")
134    protected String networkMode;
135
136    protected AbstractDockerMojo(MavenProject mavenProject, JibConfigurationService jibConfigurationService,
137                                 ApplicationConfigurationService applicationConfigurationService,
138                                 DockerService dockerService, MavenSession mavenSession, MojoExecution mojoExecution) {
139        this.mavenProject = mavenProject;
140        this.mavenSession = mavenSession;
141        this.jibConfigurationService = jibConfigurationService;
142        this.applicationConfigurationService = applicationConfigurationService;
143        this.dockerService = dockerService;
144        this.expressionEvaluator = new PluginParameterExpressionEvaluator(mavenSession, mojoExecution);
145    }
146
147    /**
148     * @return the Java version from either the <code>maven.compiler.target</code> property or the <code>java.version</code> property.
149     */
150    protected ArtifactVersion javaVersion() {
151        return new DefaultArtifactVersion(getJdkVersion());
152    }
153
154    private String getJdkVersion() {
155        var releaseVersion = getPropertyValue(mavenProject, "maven.compiler.release");
156        var targetVersion = getPropertyValue(mavenProject, "maven.compiler.target");
157        return releaseVersion.or(() -> targetVersion).orElseGet(() -> System.getProperty("java.version"));
158    }
159
160    private static Optional<String> getPropertyValue(MavenProject project, String propertName) {
161        var systemProperty = Optional.of(propertName).map(System::getProperty);
162        var properties = project.getProperties();
163        var projectProperty = Optional.of(propertName).map(properties::getProperty);
164        return systemProperty.or(() -> projectProperty);
165    }
166
167    /**
168     * @return the JVM version to use for GraalVM.
169     */
170    protected String graalVmJvmVersion() {
171        return Integer.toString(resolveGraalVersion());
172    }
173
174    /**
175     * @return the GraalVM download URL depending on the Java version.
176     */
177    protected String graalVmDownloadUrl() {
178        Integer version = resolveGraalVersion();
179
180        return GDS_DOWNLOAD_URL.formatted(version, version, graalVmArch());
181    }
182
183    private Integer resolveGraalVersion() {
184        int target = javaVersion().getMajorVersion();
185        Integer version = GRAALVM_VERSIONS.floor(target);
186
187        return version != null ? version : GRAALVM_VERSIONS.first();
188    }
189
190    /**
191     * @return the OS architecture to use for GraalVM depending on the <code>os.arch</code> system property.
192     */
193    protected String graalVmArch() {
194        return isArm() ? ARM_ARCH : X86_64_ARCH;
195    }
196
197    /**
198     * @return the base FROM image for the native image.
199     */
200    protected String getFrom() {
201        return getFromImage().orElse("ghcr.io/graalvm/native-image-community:" + graalVmTag(graalVmJvmVersion(), staticNativeImage, oracleLinuxVersion));
202    }
203
204    /**
205     * @param graalVmJvmVersion the JVM version string
206     * @param staticNativeImage whether to produce a static native image
207     * @param oracleLinuxVersion the Oracle Linux version to use
208     * @return the GraalVM Docker image tag based on the provided parameters
209     */
210    protected String graalVmTag(String graalVmJvmVersion, Boolean staticNativeImage, String oracleLinuxVersion) {
211        String suffix = Boolean.TRUE.equals(staticNativeImage)
212            ? "-muslib" + (StringUtils.hasText(oracleLinuxVersion) ? "-" + oracleLinuxVersion : "")
213            : (StringUtils.hasText(oracleLinuxVersion) ? "-" + oracleLinuxVersion : "");
214        return graalVmJvmVersion + suffix;
215    }
216
217    /**
218     * Check os.arch against known ARM architecture identifiers.
219     *
220     * @return true if we think we're running on an arm JDK
221     */
222    protected boolean isArm() {
223        return switch (System.getProperty("os.arch")) {
224            case ARM_ARCH, "arm64" -> true;
225            default -> false;
226        };
227    }
228
229    /**
230     * @return the base image from the jib configuration (if any).
231     */
232    protected Optional<String> getFromImage() {
233        return jibConfigurationService.getFromImage();
234    }
235
236    /**
237     * @return the Docker image tags by looking at the Jib plugin configuration.
238     */
239    protected Set<String> getTags() {
240        var tags = new HashSet<String>();
241        Optional<String> toImageOptional = jibConfigurationService.getToImage();
242        String imageName = mavenProject.getArtifactId();
243        if (toImageOptional.isPresent()) {
244            String toImage = toImageOptional.get();
245            if (toImage.contains(":")) {
246                tags.add(toImage);
247                imageName = toImageOptional.get().split(":")[0];
248            } else {
249                tags.add(toImage + ":" + LATEST_TAG);
250                imageName = toImage;
251            }
252        } else {
253            tags.add(imageName + ":" + LATEST_TAG);
254        }
255        for (String tag : jibConfigurationService.getTags()) {
256            if (LATEST_TAG.equals(tag) && tags.stream().anyMatch(t -> t.contains(LATEST_TAG))) {
257                continue;
258            }
259            tags.add(String.format("%s:%s", imageName, tag));
260        }
261        return tags.stream()
262            .map(this::evaluateExpression)
263            .collect(Collectors.toSet());
264    }
265
266    private String evaluateExpression(String expression) {
267        try {
268            return expressionEvaluator.evaluate(expression, String.class).toString();
269        } catch (Exception e) {
270            return expression;
271        }
272    }
273
274    /**
275     * @return the application ports to expose by looking at the Jib configuration or the application configuration.
276     */
277    protected String getPorts() {
278        return jibConfigurationService.getPorts().orElseGet(() -> {
279            String port = applicationConfigurationService.getServerPort();
280            return "-1".equals(port) ? DEFAULT_PORT : port;
281        });
282    }
283
284    /**
285     * Copy project dependencies to a <code>target/dependency</code> directory.
286     */
287    @SuppressWarnings("ResultOfMethodCallIgnored")
288    protected void copyDependencies() throws IOException {
289        var imageClasspathScopes = Arrays.asList(Artifact.SCOPE_COMPILE, Artifact.SCOPE_RUNTIME);
290        mavenProject.setArtifactFilter(artifact -> imageClasspathScopes.contains(artifact.getScope()));
291        var target = new File(mavenProject.getBuild().getDirectory(), "dependency");
292        if (!target.exists()) {
293            target.mkdirs();
294        }
295        for (Artifact dependency : mavenProject.getArtifacts()) {
296            Files.copy(dependency.getFile().toPath(), target.toPath().resolve(dependency.getFile().getName()), StandardCopyOption.REPLACE_EXISTING);
297        }
298    }
299
300    /**
301     * @return the Docker CMD command.
302     */
303    protected String getCmd() {
304        return "CMD [" +
305            appArguments.stream()
306                .map(s -> "\"" + s + "\"")
307                .collect(Collectors.joining(", ")) +
308            "]";
309    }
310
311    /**
312     * @return Networking mode for the RUN instructions during build (if any).
313     */
314    protected Optional<String> getNetworkMode() {
315        return Optional.ofNullable(networkMode);
316    }
317
318    /**
319     * @return Map of proxy-related build arguments for Docker builds.
320     */
321    protected Map<String, String> getProxyBuildArgs() {
322        var proxyArgs = new java.util.HashMap<String, String>();
323        
324        // HTTP proxy configuration from standard JVM properties
325        String httpProxyHost = System.getProperty("http.proxyHost");
326        String httpProxyPort = System.getProperty("http.proxyPort", "80");
327        if (StringUtils.hasText(httpProxyHost)) {
328            String httpProxy = "http://" + httpProxyHost + ":" + httpProxyPort;
329            proxyArgs.put("HTTP_PROXY", httpProxy);
330            proxyArgs.put("http_proxy", httpProxy);
331        }
332        
333        // HTTPS proxy configuration from standard JVM properties
334        String httpsProxyHost = System.getProperty("https.proxyHost");
335        String httpsProxyPort = System.getProperty("https.proxyPort", "443");
336        if (StringUtils.hasText(httpsProxyHost)) {
337            String httpsProxy = "http://" + httpsProxyHost + ":" + httpsProxyPort;
338            proxyArgs.put("HTTPS_PROXY", httpsProxy);
339            proxyArgs.put("https_proxy", httpsProxy);
340        }
341        
342        // No proxy configuration from standard JVM properties
343        String nonProxyHosts = System.getProperty("http.nonProxyHosts");
344        if (StringUtils.hasText(nonProxyHosts)) {
345            // Convert Java format (e.g., "*.company.com|localhost") to standard format (e.g., "*.company.com,localhost")
346            String noProxy = nonProxyHosts.replace("|", ",");
347            proxyArgs.put("NO_PROXY", noProxy);
348            proxyArgs.put("no_proxy", noProxy);
349        }
350        
351        return proxyArgs;
352    }
353
354    /**
355     * @return the base image to use for the Dockerfile.
356     */
357    protected String getBaseImage() {
358        return JibMicronautExtension.determineBaseImage(JibMicronautExtension.getJdkVersion(mavenSession), MicronautRuntime.valueOf(micronautRuntime.toUpperCase()).getBuildStrategy());
359    }
360
361    /**
362     * Adds cmd to docker oracle cloud function file.
363     *
364     * @param dockerfile the docker file
365     */
366    protected void oracleCloudFunctionCmd(File dockerfile) throws IOException {
367        if (appArguments != null && !appArguments.isEmpty()) {
368            getLog().info("Using application arguments: " + appArguments);
369            com.google.common.io.Files.asCharSink(dockerfile, Charset.defaultCharset(), FileWriteMode.APPEND).write(System.lineSeparator() + getCmd());
370        } else {
371            com.google.common.io.Files.asCharSink(dockerfile, Charset.defaultCharset(), FileWriteMode.APPEND).write(System.lineSeparator() + ORACLE_CLOUD_FUNCTION_DEFAULT_CMD);
372        }
373    }
374
375}