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;
017
018import com.google.cloud.tools.jib.api.CacheDirectoryCreationException;
019import com.google.cloud.tools.jib.api.Containerizer;
020import com.google.cloud.tools.jib.api.Credential;
021import com.google.cloud.tools.jib.api.ImageReference;
022import com.google.cloud.tools.jib.api.InvalidImageReferenceException;
023import com.google.cloud.tools.jib.api.Jib;
024import com.google.cloud.tools.jib.api.JibContainer;
025import com.google.cloud.tools.jib.api.JibContainerBuilder;
026import com.google.cloud.tools.jib.api.LogEvent;
027import com.google.cloud.tools.jib.api.RegistryException;
028import com.google.cloud.tools.jib.api.RegistryImage;
029import com.google.cloud.tools.jib.api.TarImage;
030import com.google.cloud.tools.jib.api.buildplan.AbsoluteUnixPath;
031import com.google.cloud.tools.jib.api.buildplan.FileEntriesLayer;
032import com.google.cloud.tools.jib.api.buildplan.FilePermissions;
033import com.google.cloud.tools.jib.api.buildplan.ImageFormat;
034import com.google.cloud.tools.jib.api.buildplan.Platform;
035import com.google.cloud.tools.jib.api.buildplan.Port;
036import io.micronaut.core.util.StringUtils;
037import io.micronaut.maven.core.DockerBuildStrategy;
038import io.micronaut.maven.core.MicronautRuntime;
039import io.micronaut.maven.jib.JibConfiguration;
040import io.micronaut.maven.jib.JibConfigurationService;
041import io.micronaut.maven.services.ApplicationConfigurationService;
042import io.micronaut.maven.services.DockerService;
043import org.apache.maven.execution.MavenSession;
044import org.apache.maven.model.Plugin;
045import org.apache.maven.plugin.MojoExecution;
046import org.apache.maven.plugin.MojoExecutionException;
047import org.apache.maven.plugins.annotations.Mojo;
048import org.apache.maven.plugins.annotations.Parameter;
049import org.apache.maven.plugins.annotations.ResolutionScope;
050import org.apache.maven.project.MavenProject;
051import org.codehaus.plexus.util.xml.Xpp3Dom;
052import org.slf4j.Logger;
053import org.slf4j.LoggerFactory;
054
055import javax.inject.Inject;
056import java.io.File;
057import java.io.IOException;
058import java.nio.file.Files;
059import java.nio.file.Path;
060import java.util.LinkedHashSet;
061import java.util.List;
062import java.util.Locale;
063import java.util.Optional;
064import java.util.Properties;
065import java.util.Set;
066import java.util.concurrent.ExecutionException;
067
068/**
069 * Builds a container image from a locally compiled native executable with Jib Core.
070 */
071@Mojo(name = NativeImageJibMojo.NATIVE_IMAGE_JIB_GOAL, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
072public class NativeImageJibMojo extends AbstractDockerMojo {
073
074    public static final String NATIVE_IMAGE_JIB_GOAL = "native-image-jib";
075    static final String ENABLED_PROPERTY = "micronaut.native-image.jib.enabled";
076    static final String EXECUTABLE_PROPERTY = "micronaut.native-image.jib.executable";
077    static final String BASE_IMAGE_PROPERTY = "micronaut.native-image.jib.base-image";
078    static final String ALLOW_PLATFORM_MISMATCH_PROPERTY = "micronaut.native-image.jib.allow-platform-mismatch";
079    static final String DEFAULT_APP_ROOT = "/app";
080    static final String DEFAULT_TAR_NAME = "jib-image.tar";
081    private static final String JIB_BUILD_GOAL_PROPERTY = "jib.buildGoal";
082    private static final String JIB_BUILD_GOAL_PARAMETER = "jibBuildGoal";
083    private static final String JIB_BUILD_GOAL_EXPRESSION = "${" + JIB_BUILD_GOAL_PROPERTY + "}";
084    private static final String DEFAULT_JIB_BUILD_GOAL = "buildTar";
085    private static final String INHERITED_DOCKER_BUILD_GOAL = "dockerBuild";
086    private static final Logger LOG = LoggerFactory.getLogger(NativeImageJibMojo.class);
087    private static final List<String> SUPPORTED_JIB_BUILD_GOALS = List.of(DEFAULT_JIB_BUILD_GOAL, "build");
088    private static final String GRAALVM_NATIVE_PLUGIN_KEY = "org.graalvm.buildtools:native-maven-plugin";
089    private static final String DEFAULT_USER = "65532";
090    private static final String LINUX = "linux";
091    private static final String AMD64 = "amd64";
092    private static final String ARM64 = "arm64";
093
094    /**
095     * Packages the generated native executable into a container image with Jib after native-image packaging.
096     */
097    @Parameter(property = ENABLED_PROPERTY, defaultValue = "false")
098    protected boolean enabled;
099
100    /**
101     * Native executable to copy into the image. Defaults to {@code target/<native imageName or artifactId>}.
102     */
103    @Parameter(property = EXECUTABLE_PROPERTY)
104    protected File executable;
105
106    /**
107     * Runtime base image for the native executable image. Jib {@code from.image} has higher precedence.
108     */
109    @Parameter(property = BASE_IMAGE_PROPERTY)
110    protected String nativeImageJibBaseImage;
111
112    /**
113     * Allows packaging when the host OS/architecture differs from the selected Linux container platform.
114     */
115    @Parameter(property = ALLOW_PLATFORM_MISMATCH_PROPERTY, defaultValue = "false")
116    protected boolean allowPlatformMismatch;
117
118    private final String pluginVersion;
119    private final MojoExecution mojoExecution;
120
121    @SuppressWarnings("CdiInjectionPointsInspection")
122    @Inject
123    public NativeImageJibMojo(MavenProject mavenProject, JibConfigurationService jibConfigurationService,
124                              ApplicationConfigurationService applicationConfigurationService, DockerService dockerService,
125                              MavenSession mavenSession, MojoExecution mojoExecution) {
126        super(mavenProject, jibConfigurationService, applicationConfigurationService, dockerService, mavenSession,
127            mojoExecution);
128        this.mojoExecution = mojoExecution;
129        pluginVersion = Optional.ofNullable(mojoExecution)
130            .map(MojoExecution::getPlugin)
131            .map(Plugin::getVersion)
132            .filter(StringUtils::hasText)
133            .orElse("unknown");
134    }
135
136    @Override
137    public void execute() throws MojoExecutionException {
138        if (!enabled) {
139            getLog().debug("Skipping native image Jib packaging because " + ENABLED_PROPERTY + " is not enabled.");
140            return;
141        }
142        applyDefaultJibBuildGoal();
143        validateNativeImageJibBuildGoal();
144        validateRuntime();
145        Platform platform = resolvePlatform();
146        validatePlatform(platform);
147        Path nativeExecutable = resolveExecutable();
148        if (!Files.isRegularFile(nativeExecutable)) {
149            throw new MojoExecutionException("Native executable not found: " + nativeExecutable
150                + ". Build with native-image packaging and -D" + ENABLED_PROPERTY + "=true, or set -D"
151                + EXECUTABLE_PROPERTY + "=<path>.");
152        }
153        ImageReference imageReference = parseImageReference(primaryImage());
154        JibContainerBuilder builder = createContainerBuilder(nativeExecutable, platform);
155        Containerizer containerizer = createContainerizer(imageReference);
156        try {
157            JibContainer jibContainer = containerize(builder, containerizer);
158            getLog().info("Built native image container " + jibContainer.getTargetImage());
159        } catch (InterruptedException e) {
160            Thread.currentThread().interrupt();
161            throw new MojoExecutionException("Interrupted while building native image container", e);
162        } catch (CacheDirectoryCreationException | ExecutionException | IOException | RegistryException e) {
163            throw new MojoExecutionException("Failed to build native image container with Jib: " + e.getMessage(), e);
164        }
165    }
166
167    final JibContainerBuilder createContainerBuilder(Path nativeExecutable, Platform platform) throws MojoExecutionException {
168        String baseImage = baseImage();
169        String executableName = nativeExecutable.getFileName().toString();
170        AbsoluteUnixPath containerExecutable = AbsoluteUnixPath.get(DEFAULT_APP_ROOT + "/" + executableName);
171        var executableLayer = FileEntriesLayer.builder()
172            .setName("native executable")
173            .addEntry(nativeExecutable, containerExecutable, FilePermissions.fromOctalString("755"))
174            .build();
175        return fromBaseImage(baseImage)
176            .addFileEntriesLayer(executableLayer)
177            .setEntrypoint(entrypoint(containerExecutable))
178            .setProgramArguments(programArguments())
179            .setExposedPorts(exposedPorts())
180            .setFormat(ImageFormat.Docker)
181            .setPlatforms(Set.of(platform))
182            .setWorkingDirectory(AbsoluteUnixPath.get(DEFAULT_APP_ROOT))
183            .setUser(jibConfigurationService.getUser().orElse(DEFAULT_USER));
184    }
185
186    private JibContainerBuilder fromBaseImage(String baseImage) throws MojoExecutionException {
187        if ("scratch".equals(baseImage)) {
188            return Jib.fromScratch();
189        }
190        RegistryImage fromImage = registryImage(baseImage, jibConfigurationService.getFromCredentials());
191        if (jibConfigurationService.getFromCredentials().isEmpty()) {
192            jibConfigurationService.resolveCredentialForImage(baseImage, LOG)
193                .ifPresent(credential -> addCredential(fromImage, credential));
194        }
195        return Jib.from(fromImage);
196    }
197
198    final JibContainer containerize(JibContainerBuilder builder, Containerizer containerizer)
199        throws InterruptedException, RegistryException, IOException, CacheDirectoryCreationException, ExecutionException {
200        return builder.containerize(containerizer);
201    }
202
203    private Containerizer createContainerizer(ImageReference imageReference) throws MojoExecutionException {
204        Containerizer containerizer;
205        if (DEFAULT_JIB_BUILD_GOAL.equals(jibBuildGoal)) {
206            Path output = jibConfigurationService.getOutputPathsTar()
207                .filter(StringUtils::hasText)
208                .map(Path::of)
209                .map(this::resolveTarOutputPath)
210                .orElseGet(() -> Path.of(mavenProject.getBuild().getDirectory(), DEFAULT_TAR_NAME));
211            containerizer = Containerizer.to(TarImage.at(output).named(imageReference));
212        } else {
213            RegistryImage targetImage = registryImage(imageReference.toString(), jibConfigurationService.getToCredentials());
214            if (jibConfigurationService.getToCredentials().isEmpty()) {
215                jibConfigurationService.resolveCredentialForImage(imageReference.toString(), LOG)
216                    .ifPresent(credential -> addCredential(targetImage, credential));
217            }
218            containerizer = Containerizer.to(targetImage);
219        }
220        for (String tag : additionalTags(imageReference)) {
221            containerizer.withAdditionalTag(tag);
222        }
223        return containerizer
224            .addEventHandler(LogEvent.class, this::logJibEvent)
225            .setToolName("micronaut-maven-plugin")
226            .setToolVersion(pluginVersion);
227    }
228
229    private Path resolveTarOutputPath(Path output) {
230        if (output.isAbsolute()) {
231            return output;
232        }
233        return mavenProject.getBasedir().toPath().resolve(output);
234    }
235
236    private void validateNativeImageJibBuildGoal() throws MojoExecutionException {
237        if (!SUPPORTED_JIB_BUILD_GOALS.contains(jibBuildGoal)) {
238            throw new MojoExecutionException("Unsupported jib.buildGoal '" + jibBuildGoal
239                + "' for native image Jib packaging. Supported values are: " + String.join(", ", SUPPORTED_JIB_BUILD_GOALS)
240                + ". Use docker-native packaging for Docker-backed native image builds.");
241        }
242    }
243
244    private void applyDefaultJibBuildGoal() {
245        if (INHERITED_DOCKER_BUILD_GOAL.equals(jibBuildGoal) && !hasConfiguredJibBuildGoal()) {
246            jibBuildGoal = DEFAULT_JIB_BUILD_GOAL;
247        }
248    }
249
250    private boolean hasConfiguredJibBuildGoal() {
251        return hasProperty(mavenSession.getUserProperties(), JIB_BUILD_GOAL_PROPERTY)
252            || hasProperty(mavenSession.getSystemProperties(), JIB_BUILD_GOAL_PROPERTY)
253            || hasProperty(mavenProject.getProperties(), JIB_BUILD_GOAL_PROPERTY)
254            || hasConfiguredMojoParameter(JIB_BUILD_GOAL_PARAMETER);
255    }
256
257    private static boolean hasProperty(Properties properties, String key) {
258        return properties != null && properties.containsKey(key);
259    }
260
261    private boolean hasConfiguredMojoParameter(String parameterName) {
262        if (mojoExecution == null || !(mojoExecution.getConfiguration() instanceof Xpp3Dom configuration)) {
263            return false;
264        }
265        Xpp3Dom parameter = configuration.getChild(parameterName);
266        return parameter != null && !isDescriptorDefaultJibBuildGoal(parameter);
267    }
268
269    private static boolean isDescriptorDefaultJibBuildGoal(Xpp3Dom parameter) {
270        return JIB_BUILD_GOAL_EXPRESSION.equals(parameter.getValue())
271            && INHERITED_DOCKER_BUILD_GOAL.equals(parameter.getAttribute("default-value"));
272    }
273
274    private void validateRuntime() throws MojoExecutionException {
275        MicronautRuntime runtime;
276        try {
277            runtime = MicronautRuntime.valueOf(micronautRuntime.toUpperCase(Locale.ENGLISH));
278        } catch (IllegalArgumentException e) {
279            throw new MojoExecutionException("Unsupported micronaut.runtime '" + micronautRuntime
280                + "' for native image Jib packaging.", e);
281        }
282        DockerBuildStrategy buildStrategy = runtime.getBuildStrategy();
283        if (buildStrategy == DockerBuildStrategy.LAMBDA || buildStrategy == DockerBuildStrategy.ORACLE_FUNCTION) {
284            throw new MojoExecutionException("native image Jib packaging does not support micronaut.runtime="
285                + micronautRuntime + ". Use docker-native packaging for Lambda and Oracle Function native images.");
286        }
287    }
288
289    private Path resolveExecutable() {
290        if (executable != null) {
291            return executable.toPath();
292        }
293        return Path.of(mavenProject.getBuild().getDirectory(), nativeImageName());
294    }
295
296    private String nativeImageName() {
297        return configuredNativeImageName().orElse(mavenProject.getArtifactId());
298    }
299
300    private Optional<String> configuredNativeImageName() {
301        Plugin plugin = mavenProject.getPlugin(GRAALVM_NATIVE_PLUGIN_KEY);
302        if (plugin != null && plugin.getConfiguration() instanceof Xpp3Dom configuration) {
303            Xpp3Dom imageName = configuration.getChild("imageName");
304            if (imageName != null && StringUtils.hasText(imageName.getValue())) {
305                return Optional.of(evaluateMavenExpression(imageName.getValue()));
306            }
307        }
308        return Optional.empty();
309    }
310
311    private String baseImage() throws MojoExecutionException {
312        String image = getJibFromImageSystemProperty()
313            .or(() -> getFromImage().filter(StringUtils::hasText))
314            .or(() -> Optional.ofNullable(nativeImageJibBaseImage).filter(StringUtils::hasText))
315            .or(() -> Optional.ofNullable(baseImageRun).filter(StringUtils::hasText))
316            .orElse(DEFAULT_BASE_IMAGE_GRAALVM_RUN);
317        return validateImageReference("native image Jib base image", evaluateMavenExpression(image));
318    }
319
320    private ImageReference parseImageReference(String image) throws MojoExecutionException {
321        try {
322            return ImageReference.parse(image);
323        } catch (InvalidImageReferenceException e) {
324            throw new MojoExecutionException("native image Jib target image is not a valid image reference: " + image, e);
325        }
326    }
327
328    private RegistryImage registryImage(String image, Optional<Credential> credential) throws MojoExecutionException {
329        try {
330            RegistryImage registryImage = RegistryImage.named(image);
331            credential.ifPresent(value -> addCredential(registryImage, value));
332            return registryImage;
333        } catch (InvalidImageReferenceException e) {
334            throw new MojoExecutionException("Invalid image reference for native image Jib: " + image, e);
335        }
336    }
337
338    private static void addCredential(RegistryImage image, Credential credential) {
339        image.addCredential(credential.getUsername(), credential.getPassword());
340    }
341
342    private String primaryImage() {
343        String image = jibConfigurationService.getToImage()
344            .map(this::evaluateMavenExpression)
345            .filter(StringUtils::hasText)
346            .orElse(mavenProject.getArtifactId());
347        return ensureTag(image);
348    }
349
350    private Set<String> additionalTags(ImageReference primaryImage) throws MojoExecutionException {
351        Set<String> tags = new LinkedHashSet<>();
352        String primaryTag = primaryImage.getTag().orElse(LATEST_TAG);
353        for (String tag : jibConfigurationService.getTags()) {
354            String evaluated = evaluateMavenExpression(tag);
355            if (!StringUtils.hasText(evaluated) || evaluated.equals(primaryTag)) {
356                continue;
357            }
358            if (!ImageReference.isValidTag(evaluated)) {
359                throw new MojoExecutionException("jib.to.tags contains an invalid image tag for native image Jib: " + evaluated);
360            }
361            tags.add(evaluated);
362        }
363        return tags;
364    }
365
366    private static String ensureTag(String image) {
367        int lastSlash = image.lastIndexOf('/');
368        int tagSeparator = image.indexOf(':', lastSlash + 1);
369        if (tagSeparator < 0 && !image.contains("@")) {
370            return image + ":" + LATEST_TAG;
371        }
372        return image;
373    }
374
375    private List<String> entrypoint(AbsoluteUnixPath containerExecutable) {
376        List<String> configuredEntrypoint = jibConfigurationService.getEntrypoint();
377        if (!configuredEntrypoint.isEmpty()) {
378            return configuredEntrypoint;
379        }
380        return List.of(containerExecutable.toString());
381    }
382
383    private List<String> programArguments() {
384        List<String> args = jibConfigurationService.getArgs();
385        if (!args.isEmpty()) {
386            return args;
387        }
388        if (appArguments != null) {
389            return appArguments;
390        }
391        return List.of();
392    }
393
394    private Set<Port> exposedPorts() throws MojoExecutionException {
395        String ports = validateExposedPorts("jib.container.ports", getPorts());
396        if (!StringUtils.hasText(ports)) {
397            return Set.of();
398        }
399        try {
400            return com.google.cloud.tools.jib.api.Ports.parse(List.of(ports.trim().split("\\s+")));
401        } catch (IllegalArgumentException e) {
402            throw new MojoExecutionException("native image Jib supports individual exposed ports such as 8080 or 8080/tcp: " + ports, e);
403        }
404    }
405
406    private Platform resolvePlatform() throws MojoExecutionException {
407        Set<JibConfiguration.PlatformConfiguration> configuredPlatforms = jibConfigurationService.getFromPlatforms();
408        if (configuredPlatforms.isEmpty()) {
409            return detectedPlatform();
410        }
411        if (configuredPlatforms.size() > 1) {
412            throw new MojoExecutionException("native image Jib supports exactly one target platform because it packages one local native executable.");
413        }
414        JibConfiguration.PlatformConfiguration configuredPlatform = configuredPlatforms.iterator().next();
415        String os = configuredPlatform.os().orElse(LINUX);
416        String architecture = configuredPlatform.architecture()
417            .map(NativeImageJibMojo::normalizeArchitecture)
418            .orElseThrow(() -> new MojoExecutionException("jib.from.platforms must define an architecture for native image Jib packaging."));
419        return new Platform(architecture, os);
420    }
421
422    private Platform detectedPlatform() {
423        return new Platform(normalizeArchitecture(System.getProperty("os.arch")), LINUX);
424    }
425
426    private void validatePlatform(Platform platform) throws MojoExecutionException {
427        if (!LINUX.equals(platform.getOs())) {
428            throw new MojoExecutionException("native image Jib packages Linux container images only. Configured platform is "
429                + platform.getOs() + "/" + platform.getArchitecture() + ".");
430        }
431        if (allowPlatformMismatch) {
432            return;
433        }
434        String hostOs = System.getProperty("os.name").toLowerCase(Locale.ENGLISH);
435        if (!hostOs.contains(LINUX)) {
436            throw new MojoExecutionException("native image Jib packaging requires a Linux host by default because the local native executable is copied into a Linux container image. "
437                + "Use docker-native packaging for Docker-backed cross-platform builds, or set -D" + ALLOW_PLATFORM_MISMATCH_PROPERTY + "=true for a known Linux cross-compiled executable.");
438        }
439        String hostArchitecture = normalizeArchitecture(System.getProperty("os.arch"));
440        if (!hostArchitecture.equals(platform.getArchitecture())) {
441            throw new MojoExecutionException("native image Jib host architecture " + hostArchitecture
442                + " does not match configured target architecture " + platform.getArchitecture()
443                + ". Set -D" + ALLOW_PLATFORM_MISMATCH_PROPERTY + "=true only for a known compatible cross-compiled executable.");
444        }
445    }
446
447    private static String normalizeArchitecture(String architecture) {
448        return switch (architecture) {
449            case "x86_64", "x64", AMD64 -> AMD64;
450            case "aarch64", ARM64 -> ARM64;
451            default -> architecture;
452        };
453    }
454
455    private String evaluateMavenExpression(String expression) {
456        try {
457            return expressionEvaluator.evaluate(expression, String.class).toString();
458        } catch (Exception e) {
459            LOG.debug("Could not evaluate Maven expression '{}'", expression, e);
460            return expression;
461        }
462    }
463
464    private void logJibEvent(LogEvent event) {
465        switch (event.getLevel()) {
466            case ERROR -> getLog().error(event.getMessage());
467            case WARN -> getLog().warn(event.getMessage());
468            case DEBUG -> getLog().debug(event.getMessage());
469            default -> getLog().info(event.getMessage());
470        }
471    }
472}