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.cloud.tools.jib.api.ImageReference;
019import com.google.cloud.tools.jib.api.InvalidImageReferenceException;
020import com.google.common.io.FileWriteMode;
021import io.micronaut.core.util.StringUtils;
022import io.micronaut.maven.core.DockerBuildStrategy;
023import io.micronaut.maven.core.MicronautRuntime;
024import io.micronaut.maven.core.MojoUtils;
025import io.micronaut.maven.jib.JibConfigurationService;
026import io.micronaut.maven.jib.JibMicronautExtension;
027import io.micronaut.maven.services.ApplicationConfigurationService;
028import io.micronaut.maven.services.DockerService;
029import org.apache.maven.artifact.Artifact;
030import org.apache.maven.artifact.versioning.ArtifactVersion;
031import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
032import org.apache.maven.execution.MavenSession;
033import org.apache.maven.plugin.MojoExecutionException;
034import org.apache.maven.plugin.MojoExecution;
035import org.apache.maven.plugin.PluginParameterExpressionEvaluator;
036import org.apache.maven.plugins.annotations.Parameter;
037import org.apache.maven.project.MavenProject;
038
039import java.io.File;
040import java.io.IOException;
041import java.net.URI;
042import java.net.URISyntaxException;
043import java.nio.charset.Charset;
044import java.nio.file.Files;
045import java.nio.file.StandardCopyOption;
046import java.util.ArrayList;
047import java.util.Arrays;
048import java.util.HashSet;
049import java.util.List;
050import java.util.Locale;
051import java.util.Map;
052import java.util.NavigableSet;
053import java.util.Optional;
054import java.util.Set;
055import java.util.TreeSet;
056import java.util.stream.Collectors;
057import java.util.regex.Matcher;
058import java.util.regex.Pattern;
059
060import static io.micronaut.maven.services.ApplicationConfigurationService.DEFAULT_PORT;
061
062/**
063 * Abstract base class for mojos related to Docker files and builds.
064 *
065 * @author Álvaro Sánchez-Mariscal
066 * @author Iván López
067 * @since 1.1
068 */
069public abstract class AbstractDockerMojo extends AbstractMicronautMojo {
070
071    public static final String LATEST_TAG = "latest";
072    public static final String DEFAULT_BASE_IMAGE_GRAALVM_RUN = "cgr.dev/chainguard/wolfi-base@sha256:52e71f61c6afd1f8d2625cff4465d8ecee156668ca665f7e9c582d1cc914eb6a";
073    public static final String DEFAULT_BASE_IMAGE_GRAALVM_BUILD = "container-registry.oracle.com/graalvm/native-image";
074    public static final String MOSTLY_STATIC_NATIVE_IMAGE_GRAALVM_FLAG = "-H:+StaticExecutableWithDynamicLibC";
075    public static final String ARM_ARCH = "aarch64";
076    public static final String X86_64_ARCH = "x64";
077    public static final String ORACLE_CLOUD_FUNCTION_DEFAULT_CMD = "CMD [\"io.micronaut.oraclecloud.function.http.HttpFunction::handleRequest\"]";
078    public static final String GDS_DOWNLOAD_URL = "https://gds.oracle.com/download/graal/%s/latest-gftc/graalvm-jdk-%s_linux-%s_bin.tar.gz";
079    public static final String LAMBDA_BOOTSTRAP_DOCKER_COMMAND_PLACEHOLDER = "${LAMBDA_BOOTSTRAP_DOCKER_COMMAND}";
080    protected static final String JIB_BUILD_GOAL_DOCKER_BUILD = "dockerBuild";
081    protected static final String JIB_BUILD_GOAL_BUILD = "build";
082    protected static final String JIB_BUILD_GOAL_BUILD_TAR = "buildTar";
083    protected static final List<String> SUPPORTED_JIB_BUILD_GOALS = List.of(
084        JIB_BUILD_GOAL_DOCKER_BUILD,
085        JIB_BUILD_GOAL_BUILD,
086        JIB_BUILD_GOAL_BUILD_TAR
087    );
088    static final String JIB_FROM_IMAGE_PROPERTY = "jib.from.image";
089    private static final String DEPENDENCY_DIRECTORY = "dependency";
090    private static final String RELEASE_DEPENDENCY_DIRECTORY = "release";
091    private static final String SNAPSHOT_DEPENDENCY_DIRECTORY = "snapshot";
092    private static final Pattern LEADING_MAJOR_VERSION = Pattern.compile("([1-9][0-9]*)(?:[.-].*)?");
093    private static final NavigableSet<Integer> GRAALVM_VERSIONS = new TreeSet<>(Set.of(25));
094    private static final List<String> DEFAULT_LAMBDA_BOOTSTRAP_ARGUMENTS = List.of(
095        "-XX:MaximumHeapSizePercent=80",
096        "-Dio.netty.allocator.numDirectArenas=0",
097        "-Dio.netty.noPreferDirect=true",
098        "-Djava.library.path=$(pwd)"
099    );
100
101    protected final MavenProject mavenProject;
102    protected final MavenSession mavenSession;
103    protected final JibConfigurationService jibConfigurationService;
104    protected final ApplicationConfigurationService applicationConfigurationService;
105    protected final DockerService dockerService;
106    protected final PluginParameterExpressionEvaluator expressionEvaluator;
107
108
109    /**
110     * Additional arguments that will be passed to the <code>native-image</code> executable. Note that this will only
111     * be used when using a packaging of type <code>docker-native</code>. For <code>native-image</code> packaging
112     * you should use the
113     * <a href="https://www.graalvm.org/reference-manual/native-image/NativeImageMavenPlugin/#maven-plugin-customization">
114     * Native Image Maven Plugin
115     * </a> configuration options.
116     */
117    @Parameter(property = "micronaut.native-image.args")
118    protected List<String> nativeImageBuildArgs;
119
120    /**
121     * List of additional arguments that will be passed to the application.
122     */
123    @Parameter(property = RunMojo.MN_APP_ARGS)
124    protected List<String> appArguments;
125
126    /**
127     * Additional arguments that will be appended to the generated AWS Lambda native bootstrap command.
128     *
129     * @since 5.0.0
130     */
131    @Parameter(property = "micronaut.lambda.bootstrap.args")
132    protected List<String> lambdaBootstrapArguments;
133
134    /**
135     * The main class of the application, as defined in the
136     * <a href="https://www.mojohaus.org/exec-maven-plugin/java-mojo.html#mainClass">Exec Maven Plugin</a>.
137     */
138    @Parameter(defaultValue = RunMojo.EXEC_MAIN_CLASS, required = true)
139    protected String mainClass;
140
141    /**
142     * Whether to produce a static native image when using <code>docker-native</code> packaging.
143     */
144    @Parameter(defaultValue = "false", property = "micronaut.native-image.static")
145    protected Boolean staticNativeImage;
146
147    /**
148     * The target runtime of the application.
149     */
150    @Parameter(property = MicronautRuntime.PROPERTY, defaultValue = "NONE")
151    protected String micronautRuntime;
152
153    /**
154     * The Docker image used to run the native image.
155     *
156     * @since 1.2
157     */
158    @Parameter(property = "micronaut.native-image.base-image-run", defaultValue = DEFAULT_BASE_IMAGE_GRAALVM_RUN)
159    protected String baseImageRun;
160
161    /**
162     * The builder-stage base image used to build the native image for docker-native packaging variants.
163     *
164     * @since 5.0.0
165     */
166    @Parameter(property = "micronaut.native-image.base-image")
167    protected String baseImage;
168
169    /**
170     * The version of Oracle Linux to use as a native-compile base when building a native image inside a Docker container.
171     */
172    @Parameter(property = "micronaut.native-image.ol.version", defaultValue = "ol9")
173    protected String oracleLinuxVersion;
174
175    /**
176     * Networking mode for the RUN instructions during build.
177     *
178     * @since 4.0.0
179     */
180    @Parameter(property = "docker.networkMode")
181    protected String networkMode;
182
183    /**
184     * <p>
185     * Jib goal used to build Docker images for {@code docker} packaging.
186     * </p>
187     * <p>
188     * Defaults to {@code dockerBuild}. Set it to {@code buildTar} or {@code build} to avoid talking to a local Docker daemon during {@code package}.
189     * </p>
190     *
191     * @since 5.0.0
192     */
193    @Parameter(property = "jib.buildGoal", defaultValue = "dockerBuild")
194    protected String jibBuildGoal;
195
196    protected AbstractDockerMojo(MavenProject mavenProject, JibConfigurationService jibConfigurationService,
197                                 ApplicationConfigurationService applicationConfigurationService,
198                                 DockerService dockerService, MavenSession mavenSession, MojoExecution mojoExecution) {
199        this.mavenProject = mavenProject;
200        this.mavenSession = mavenSession;
201        this.jibConfigurationService = jibConfigurationService;
202        this.applicationConfigurationService = applicationConfigurationService;
203        this.dockerService = dockerService;
204        this.expressionEvaluator = new PluginParameterExpressionEvaluator(mavenSession, mojoExecution);
205    }
206
207    /**
208     * @return the Java version from either the <code>maven.compiler.target</code> property or the <code>java.version</code> property.
209     */
210    protected ArtifactVersion javaVersion() {
211        return new DefaultArtifactVersion(getJdkVersion());
212    }
213
214    private String getJdkVersion() {
215        var releaseVersion = getPropertyValue(mavenProject, "maven.compiler.release");
216        var targetVersion = getPropertyValue(mavenProject, "maven.compiler.target");
217        return releaseVersion.or(() -> targetVersion).orElseGet(() -> System.getProperty("java.version"));
218    }
219
220    private static Optional<String> getPropertyValue(MavenProject project, String propertName) {
221        var systemProperty = Optional.of(propertName).map(System::getProperty);
222        var properties = project.getProperties();
223        var projectProperty = Optional.of(propertName).map(properties::getProperty);
224        return systemProperty.or(() -> projectProperty);
225    }
226
227    protected final void validateJibBuildGoal() throws MojoExecutionException {
228        if (!SUPPORTED_JIB_BUILD_GOALS.contains(jibBuildGoal)) {
229            throw new MojoExecutionException("Unsupported jib.buildGoal '" + jibBuildGoal
230                + "'. Supported values are: " + String.join(", ", SUPPORTED_JIB_BUILD_GOALS));
231        }
232    }
233
234    protected final Optional<String> getConfiguredToImage() {
235        return jibConfigurationService.getToImage().filter(StringUtils::hasText);
236    }
237
238    protected final String requireConfiguredToImageForJibRegistryBuild() throws MojoExecutionException {
239        return getConfiguredToImage()
240            .orElseThrow(() -> new MojoExecutionException("jib.buildGoal=build requires a configured target image. "
241                + "Set jib.to.image to the registry image to publish, and configure registry credentials with "
242                + "Jib-supported options such as jib.to.auth.*, jib.to.credHelper, Maven settings, or environment-backed "
243                + "configuration."));
244    }
245
246    protected final boolean shouldBuildWithDockerfile(File providedDockerfile) {
247        var runtime = MicronautRuntime.valueOf(micronautRuntime.toUpperCase());
248        return providedDockerfile.exists() || runtime.getBuildStrategy() == DockerBuildStrategy.ORACLE_FUNCTION;
249    }
250
251    /**
252     * @return the JVM version to use for GraalVM.
253     */
254    protected String graalVmJvmVersion() {
255        return Integer.toString(resolveGraalVersion());
256    }
257
258    /**
259     * @return the GraalVM download URL depending on the Java version.
260     */
261    protected String graalVmDownloadUrl() {
262        Integer version = resolveGraalVersion();
263
264        return GDS_DOWNLOAD_URL.formatted(version, version, graalVmArch());
265    }
266
267    private Integer resolveGraalVersion() {
268        int target = javaVersion().getMajorVersion();
269        Integer version = GRAALVM_VERSIONS.floor(target);
270
271        return version != null ? version : GRAALVM_VERSIONS.first();
272    }
273
274    /**
275     * @return the OS architecture to use for GraalVM depending on the <code>os.arch</code> system property.
276     */
277    protected String graalVmArch() {
278        return isArm() ? ARM_ARCH : X86_64_ARCH;
279    }
280
281    /**
282     * @return the base FROM image for the native image.
283     */
284    protected String getFrom() {
285        return getJibFromImageSystemProperty()
286            .or(() -> Optional.ofNullable(baseImage).filter(StringUtils::hasText))
287            .or(() -> getFromImage().filter(StringUtils::hasText))
288            .orElse(DEFAULT_BASE_IMAGE_GRAALVM_BUILD + ":" + graalVmTag(graalVmJvmVersion(), staticNativeImage, oracleLinuxVersion));
289    }
290
291    /**
292     * @return whether the selected native-image builder is known to support {@code -H:+SharedArenaSupport}.
293     */
294    protected boolean supportsSharedArena() {
295        return sharedArenaBuilderMajorVersion()
296            .map(MojoUtils::supportsSharedArena)
297            .orElse(false);
298    }
299
300    private Optional<Integer> sharedArenaBuilderMajorVersion() {
301        return getJibFromImageSystemProperty()
302            .or(() -> Optional.ofNullable(baseImage).filter(StringUtils::hasText))
303            .or(() -> getFromImage().filter(StringUtils::hasText))
304            .map(AbstractDockerMojo::graalVmNativeImageBuilderMajorVersion)
305            .orElseGet(() -> Optional.of(resolveGraalVersion()));
306    }
307
308    static Optional<Integer> graalVmNativeImageBuilderMajorVersion(String image) {
309        if (!StringUtils.hasText(image)) {
310            return Optional.empty();
311        }
312
313        int digestStart = image.indexOf('@');
314        String imageWithoutDigest = digestStart >= 0 ? image.substring(0, digestStart) : image;
315        int lastSlash = imageWithoutDigest.lastIndexOf('/');
316        int tagSeparator = imageWithoutDigest.lastIndexOf(':');
317        if (tagSeparator <= lastSlash) {
318            return Optional.empty();
319        }
320
321        String repository = imageWithoutDigest.substring(0, tagSeparator).toLowerCase(Locale.ROOT);
322        if (!isGraalVmNativeImageRepository(repository)) {
323            return Optional.empty();
324        }
325
326        String tag = imageWithoutDigest.substring(tagSeparator + 1);
327        Matcher matcher = LEADING_MAJOR_VERSION.matcher(tag);
328        if (!matcher.matches()) {
329            return Optional.empty();
330        }
331        try {
332            return Optional.of(Integer.parseInt(matcher.group(1)));
333        } catch (NumberFormatException e) {
334            return Optional.empty();
335        }
336    }
337
338    private static boolean isGraalVmNativeImageRepository(String repository) {
339        int lastSlash = repository.lastIndexOf('/');
340        String imageName = lastSlash >= 0 ? repository.substring(lastSlash + 1) : repository;
341        return (repository.startsWith("graalvm/") || repository.contains("/graalvm/"))
342            && imageName.startsWith("native-image");
343    }
344
345    /**
346     * @param graalVmJvmVersion the JVM version string
347     * @param staticNativeImage whether to produce a static native image
348     * @param oracleLinuxVersion the Oracle Linux version to use
349     * @return the GraalVM Docker image tag based on the provided parameters
350     */
351    protected String graalVmTag(String graalVmJvmVersion, Boolean staticNativeImage, String oracleLinuxVersion) {
352        String suffix = Boolean.TRUE.equals(staticNativeImage)
353            ? "-muslib" + (StringUtils.hasText(oracleLinuxVersion) ? "-" + oracleLinuxVersion : "")
354            : (StringUtils.hasText(oracleLinuxVersion) ? "-" + oracleLinuxVersion : "");
355        return graalVmJvmVersion + suffix;
356    }
357
358    /**
359     * Check os.arch against known ARM architecture identifiers.
360     *
361     * @return true if we think we're running on an arm JDK
362     */
363    protected boolean isArm() {
364        return switch (System.getProperty("os.arch")) {
365            case ARM_ARCH, "arm64" -> true;
366            default -> false;
367        };
368    }
369
370    /**
371     * @return the base image from the jib configuration (if any).
372     */
373    protected Optional<String> getFromImage() {
374        return jibConfigurationService.getFromImage();
375    }
376
377    /**
378     * @return the base image from the Jib system property override, if any.
379     */
380    protected Optional<String> getJibFromImageSystemProperty() {
381        return Optional.ofNullable(System.getProperty(JIB_FROM_IMAGE_PROPERTY))
382            .filter(StringUtils::hasText);
383    }
384
385    /**
386     * @return the Docker image tags by looking at the Jib plugin configuration.
387     */
388    protected Set<String> getTags() {
389        var tags = new HashSet<String>();
390        Optional<String> toImageOptional = jibConfigurationService.getToImage();
391        String imageName = mavenProject.getArtifactId();
392        if (toImageOptional.isPresent()) {
393            String toImage = toImageOptional.get();
394            if (toImage.contains(":")) {
395                tags.add(toImage);
396                imageName = toImageOptional.get().split(":")[0];
397            } else {
398                tags.add(toImage + ":" + LATEST_TAG);
399                imageName = toImage;
400            }
401        } else {
402            tags.add(imageName + ":" + LATEST_TAG);
403        }
404        for (String tag : jibConfigurationService.getTags()) {
405            if (LATEST_TAG.equals(tag) && tags.stream().anyMatch(t -> t.contains(LATEST_TAG))) {
406                continue;
407            }
408            tags.add(String.format("%s:%s", imageName, tag));
409        }
410        return tags.stream()
411            .map(this::evaluateExpression)
412            .collect(Collectors.toSet());
413    }
414
415    private String evaluateExpression(String expression) {
416        try {
417            return expressionEvaluator.evaluate(expression, String.class).toString();
418        } catch (Exception e) {
419            return expression;
420        }
421    }
422
423    /**
424     * @return the application ports to expose by looking at the Jib configuration or the application configuration.
425     */
426    protected String getPorts() {
427        return jibConfigurationService.getPorts().orElseGet(() -> {
428            String port = applicationConfigurationService.getServerPort();
429            return "-1".equals(port) ? DEFAULT_PORT : port;
430        });
431    }
432
433    /**
434     * Copy project dependencies to a <code>target/dependency</code> directory.
435     */
436    protected void copyDependencies() throws IOException {
437        var imageClasspathScopes = Arrays.asList(Artifact.SCOPE_COMPILE, Artifact.SCOPE_RUNTIME);
438        var target = new File(mavenProject.getBuild().getDirectory(), DEPENDENCY_DIRECTORY).toPath();
439        Files.createDirectories(target);
440        Files.createDirectories(target.resolve(RELEASE_DEPENDENCY_DIRECTORY));
441        Files.createDirectories(target.resolve(SNAPSHOT_DEPENDENCY_DIRECTORY));
442        for (Artifact dependency : mavenProject.getArtifacts()) {
443            if (!imageClasspathScopes.contains(dependency.getScope())) {
444                continue;
445            }
446            var dependencyFile = dependency.getFile().toPath();
447            var dependencyName = dependency.getFile().getName();
448            var layeredPath = target.resolve(dependencyLayerDirectory(dependency)).resolve(dependencyName);
449            Files.copy(dependencyFile, layeredPath, StandardCopyOption.REPLACE_EXISTING);
450            Files.copy(dependencyFile, target.resolve(dependencyName), StandardCopyOption.REPLACE_EXISTING);
451        }
452    }
453
454    private static String dependencyLayerDirectory(Artifact dependency) {
455        return dependency.isSnapshot() ? SNAPSHOT_DEPENDENCY_DIRECTORY : RELEASE_DEPENDENCY_DIRECTORY;
456    }
457
458    /**
459     * @return the Docker CMD command.
460     */
461    protected String getCmd() throws MojoExecutionException {
462        var escapedArguments = new ArrayList<String>(appArguments.size());
463        for (String argument : appArguments) {
464            escapedArguments.add(jsonStringLiteral("mn.app.args", argument));
465        }
466        return "CMD [" + String.join(", ", escapedArguments) + "]";
467    }
468
469    /**
470     * @return the generated AWS Lambda bootstrap command.
471     */
472    protected String getLambdaBootstrapCommand() throws MojoExecutionException {
473        var command = new StringBuilder("./func");
474        for (String bootstrapArgument : DEFAULT_LAMBDA_BOOTSTRAP_ARGUMENTS) {
475            command.append(' ').append(bootstrapArgument);
476        }
477        if (lambdaBootstrapArguments != null && !lambdaBootstrapArguments.isEmpty()) {
478            for (String lambdaBootstrapArgument : lambdaBootstrapArguments) {
479                command.append(' ').append(escapeBootstrapArgument(lambdaBootstrapArgument));
480            }
481        }
482        return command.toString();
483    }
484
485    /**
486     * Applies the generated AWS Lambda bootstrap script to a dockerfile template.
487     *
488     * @param dockerfile the docker file
489     */
490    protected void lambdaBootstrapCommand(File dockerfile) throws IOException, MojoExecutionException {
491        if (dockerfile == null) {
492            return;
493        }
494        String lambdaBootstrapDockerCommand = getLambdaBootstrapDockerCommand();
495        if (lambdaBootstrapArguments != null && !lambdaBootstrapArguments.isEmpty()) {
496            getLog().info("Using AWS Lambda bootstrap arguments: " + lambdaBootstrapArguments);
497        }
498        var allLines = Files.readAllLines(dockerfile.toPath());
499        var result = new ArrayList<String>(allLines.size());
500        for (String line : allLines) {
501            if (line.contains(LAMBDA_BOOTSTRAP_DOCKER_COMMAND_PLACEHOLDER)) {
502                result.add(line.replace(LAMBDA_BOOTSTRAP_DOCKER_COMMAND_PLACEHOLDER, lambdaBootstrapDockerCommand));
503            } else {
504                result.add(line);
505            }
506        }
507        Files.write(dockerfile.toPath(), result);
508    }
509
510    private String getLambdaBootstrapDockerCommand() throws MojoExecutionException {
511        var quotedLines = new ArrayList<String>();
512        for (String line : List.of("#!/bin/sh", "set -euo pipefail", getLambdaBootstrapCommand())) {
513            quotedLines.add(quoteShellLiteral("AWS Lambda bootstrap command", line));
514        }
515        return "printf '%s\\n' " + String.join(" ", quotedLines) + " > bootstrap";
516    }
517
518    private static String escapeBootstrapArgument(String argument) throws MojoExecutionException {
519        String sanitized = validateDockerfileValue("micronaut.lambda.bootstrap.args", argument);
520        if (isShellSafe(sanitized)) {
521            return sanitized;
522        }
523        return quoteShellLiteral("micronaut.lambda.bootstrap.args", sanitized);
524    }
525
526    private static boolean isShellSafe(String argument) {
527        if (argument == null || argument.isEmpty()) {
528            return false;
529        }
530        for (int i = 0; i < argument.length(); i++) {
531            char c = argument.charAt(i);
532            if (!Character.isLetterOrDigit(c)
533                && "_@%+=:,./-".indexOf(c) == -1) {
534                return false;
535            }
536        }
537        return true;
538    }
539
540    static String quoteShellLiteral(String source, String value) throws MojoExecutionException {
541        validateDockerfileValue(source, value);
542        return "'" + value.replace("'", "'\"'\"'") + "'";
543    }
544
545    protected static String validateDockerfileValue(String source, String value) throws MojoExecutionException {
546        if (value == null) {
547            throw new MojoExecutionException(source + " must not be null when generating a Dockerfile");
548        }
549        for (int i = 0; i < value.length(); i++) {
550            char c = value.charAt(i);
551            if (Character.isISOControl(c)) {
552                throw new MojoExecutionException(source + " contains an unsupported control character at index " + i
553                    + " and cannot be written into a generated Dockerfile");
554            }
555        }
556        return value;
557    }
558
559    protected static String validateImageReference(String source, String value) throws MojoExecutionException {
560        String sanitized = validateDockerfileValue(source, value);
561        try {
562            ImageReference.parse(sanitized);
563        } catch (InvalidImageReferenceException e) {
564            throw new MojoExecutionException(source + " is not a valid Docker image reference: " + sanitized, e);
565        }
566        return sanitized;
567    }
568
569    protected static String validateExposedPorts(String source, String value) throws MojoExecutionException {
570        String sanitized = validateDockerfileValue(source, value);
571        for (String token : sanitized.split("\\s+")) {
572            if (token.isEmpty()) {
573                continue;
574            }
575            if (!token.matches("\\d+(/(?:tcp|udp))?")) {
576                throw new MojoExecutionException(source + " contains an invalid exposed port token: " + token);
577            }
578        }
579        return sanitized;
580    }
581
582    protected static String validateDownloadUrl(String source, String value) throws MojoExecutionException {
583        String sanitized = validateDockerfileValue(source, value);
584        try {
585            URI uri = new URI(sanitized);
586            String scheme = uri.getScheme();
587            if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
588                throw new MojoExecutionException(source + " must use an http or https URL: " + sanitized);
589            }
590            if (!uri.isAbsolute()) {
591                throw new MojoExecutionException(source + " must be an absolute URL: " + sanitized);
592            }
593        } catch (URISyntaxException e) {
594            throw new MojoExecutionException(source + " is not a valid URL: " + sanitized, e);
595        }
596        return sanitized;
597    }
598
599    protected static String jsonStringLiteral(String source, String value) throws MojoExecutionException {
600        return "\"" + escapeJsonString(source, value) + "\"";
601    }
602
603    protected static String escapeJsonString(String source, String value) throws MojoExecutionException {
604        String sanitized = validateDockerfileValue(source, value);
605        var result = new StringBuilder(sanitized.length() + 8);
606        for (int i = 0; i < sanitized.length(); i++) {
607            char c = sanitized.charAt(i);
608            switch (c) {
609                case '"' -> result.append("\\\"");
610                case '\\' -> result.append("\\\\");
611                case '\b' -> result.append("\\b");
612                case '\f' -> result.append("\\f");
613                case '\n' -> result.append("\\n");
614                case '\r' -> result.append("\\r");
615                case '\t' -> result.append("\\t");
616                default -> {
617                    if (c < 0x20) {
618                        result.append(String.format("\\u%04x", (int) c));
619                    } else {
620                        result.append(c);
621                    }
622                }
623            }
624        }
625        return result.toString();
626    }
627
628    protected static String shellLiteral(String source, String value) throws MojoExecutionException {
629        return quoteShellLiteral(source, validateDockerfileValue(source, value));
630    }
631
632    protected static String escapeShellDoubleQuoted(String source, String value) throws MojoExecutionException {
633        return validateDockerfileValue(source, value)
634            .replace("\\", "\\\\")
635            .replace("\"", "\\\"")
636            .replace("$", "\\$")
637            .replace("`", "\\`");
638    }
639
640    /**
641     * @return Networking mode for the RUN instructions during build (if any).
642     */
643    protected Optional<String> getNetworkMode() {
644        return Optional.ofNullable(networkMode);
645    }
646
647    /**
648     * @return Map of proxy-related build arguments for Docker builds.
649     */
650    protected Map<String, String> getProxyBuildArgs() {
651        var proxyArgs = new java.util.HashMap<String, String>();
652        
653        // HTTP proxy configuration from standard JVM properties
654        String httpProxyHost = System.getProperty("http.proxyHost");
655        String httpProxyPort = System.getProperty("http.proxyPort", "80");
656        if (StringUtils.hasText(httpProxyHost)) {
657            String httpProxy = "http://" + httpProxyHost + ":" + httpProxyPort;
658            proxyArgs.put("HTTP_PROXY", httpProxy);
659            proxyArgs.put("http_proxy", httpProxy);
660        }
661        
662        // HTTPS proxy configuration from standard JVM properties
663        String httpsProxyHost = System.getProperty("https.proxyHost");
664        String httpsProxyPort = System.getProperty("https.proxyPort", "443");
665        if (StringUtils.hasText(httpsProxyHost)) {
666            String httpsProxy = "http://" + httpsProxyHost + ":" + httpsProxyPort;
667            proxyArgs.put("HTTPS_PROXY", httpsProxy);
668            proxyArgs.put("https_proxy", httpsProxy);
669        }
670        
671        // No proxy configuration from standard JVM properties
672        String nonProxyHosts = System.getProperty("http.nonProxyHosts");
673        if (StringUtils.hasText(nonProxyHosts)) {
674            // Convert Java format (e.g., "*.company.com|localhost") to standard format (e.g., "*.company.com,localhost")
675            String noProxy = nonProxyHosts.replace("|", ",");
676            proxyArgs.put("NO_PROXY", noProxy);
677            proxyArgs.put("no_proxy", noProxy);
678        }
679        
680        return proxyArgs;
681    }
682
683    /**
684     * @return the base image to use for the Dockerfile.
685     */
686    protected String getBaseImage() {
687        return JibMicronautExtension.determineBaseImage(JibMicronautExtension.getJdkVersion(mavenSession), MicronautRuntime.valueOf(micronautRuntime.toUpperCase()).getBuildStrategy());
688    }
689
690    /**
691     * Adds cmd to docker oracle cloud function file.
692     *
693     * @param dockerfile the docker file
694     */
695    protected void oracleCloudFunctionCmd(File dockerfile) throws IOException, MojoExecutionException {
696        if (appArguments != null && !appArguments.isEmpty()) {
697            getLog().info("Using application arguments: " + appArguments);
698            com.google.common.io.Files.asCharSink(dockerfile, Charset.defaultCharset(), FileWriteMode.APPEND)
699                .write(System.lineSeparator() + getCmd());
700        } else {
701            com.google.common.io.Files.asCharSink(dockerfile, Charset.defaultCharset(), FileWriteMode.APPEND).write(System.lineSeparator() + ORACLE_CLOUD_FUNCTION_DEFAULT_CMD);
702        }
703    }
704
705}