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.github.dockerjava.api.command.BuildImageCmd;
019import com.google.cloud.tools.jib.api.ImageReference;
020import com.google.cloud.tools.jib.api.InvalidImageReferenceException;
021import io.micronaut.core.annotation.Experimental;
022import io.micronaut.maven.core.MicronautRuntime;
023import io.micronaut.maven.jib.JibConfigurationService;
024import io.micronaut.maven.services.ApplicationConfigurationService;
025import io.micronaut.maven.services.DockerService;
026import org.apache.commons.io.IOUtils;
027import org.apache.maven.execution.MavenSession;
028import org.apache.maven.plugin.MojoExecution;
029import org.apache.maven.plugin.MojoExecutionException;
030import org.apache.maven.plugins.annotations.Mojo;
031import org.apache.maven.plugins.annotations.Parameter;
032import org.apache.maven.plugins.annotations.ResolutionScope;
033import org.apache.maven.project.MavenProject;
034import org.apache.maven.shared.filtering.MavenFilteringException;
035import org.apache.maven.shared.filtering.MavenReaderFilter;
036import org.apache.maven.shared.filtering.MavenReaderFilterRequest;
037
038import javax.inject.Inject;
039import java.io.File;
040import java.io.IOException;
041import java.io.InputStream;
042import java.io.InputStreamReader;
043import java.io.Reader;
044import java.io.Writer;
045import java.nio.file.Files;
046import java.nio.file.Path;
047import java.nio.file.StandardOpenOption;
048import java.nio.file.attribute.PosixFilePermission;
049import java.util.Collections;
050import java.util.EnumSet;
051import java.util.Properties;
052
053/**
054 * <p>Implementation of the <code>docker-crac</code> packaging.</p>
055 * <p><strong>WARNING</strong>: this goal is not intended to be executed directly. Instead, specify the packaging type
056 * using the <code>packaging</code> property, eg:</p>
057 *
058 * <pre>mvn package -Dpackaging=docker-crac</pre>
059 * <p>
060 * This is a two stage process. First a docker image is built that runs the application under a CRaC enabled JDK. Then
061 * the application is warmed up via a shell script. And then a checkpoint is taken via a signal using jcmd.
062 * <p>
063 * The second stage takes this checkpoint, and creates the final image containing it plus a run script which passes the
064 * correct flags to the CRaC enabled JDK.
065 *
066 * @author Tim Yates
067 * @since 3.5.0
068 */
069@Experimental
070@Mojo(name = DockerCracMojo.DOCKER_CRAC_PACKAGING, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
071public class DockerCracMojo extends AbstractDockerMojo {
072
073    public static final String DOCKER_CRAC_PACKAGING = "docker-crac";
074    public static final String CHECKPOINT_SCRIPT_NAME = "checkpoint.sh";
075    public static final String WARMUP_SCRIPT_NAME = "warmup.sh";
076    public static final String RUN_SCRIPT_NAME = "run.sh";
077    public static final String DEFAULT_READINESS_COMMAND = "curl --output /dev/null --silent --head http://localhost:8080";
078    public static final String CRAC_READINESS_PROPERTY = "crac.readiness";
079    public static final String DEFAULT_CRAC_CHECKPOINT_TIMEOUT = "60";
080    public static final String CRAC_CHECKPOINT_NETWORK_PROPERTY = "crac.checkpoint.network";
081    public static final String CRAC_CHECKPOINT_TIMEOUT_PROPERTY = "crac.checkpoint.timeout";
082
083    public static final String CRAC_JAVA_VERSION = "crac.java.version";
084
085    public static final String CRAC_ARCHITECTURE = "crac.arch";
086
087    public static final String CRAC_OS = "crac.os";
088    public static final String DEFAULT_CRAC_OS = "linux-glibc";
089
090    public static final String DEFAULT_BASE_IMAGE = "ubuntu:22.04";
091
092    public static final String ARM_ARCH = "aarch64";
093    public static final String X86_64_ARCH = "amd64";
094
095    private static final EnumSet<PosixFilePermission> POSIX_FILE_PERMISSIONS = EnumSet.of(
096        PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE,
097        PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE, PosixFilePermission.GROUP_EXECUTE,
098        PosixFilePermission.OTHERS_READ, PosixFilePermission.OTHERS_EXECUTE
099    );
100    private final MavenReaderFilter mavenReaderFilter;
101
102    /**
103     * The command to execute to determine if the application is ready to receive traffic.
104     */
105    @Parameter(property = DockerCracMojo.CRAC_READINESS_PROPERTY, defaultValue = DockerCracMojo.DEFAULT_READINESS_COMMAND)
106    private String readinessCommand;
107
108    /**
109     * The timeout in seconds to wait for the checkpoint to complete.
110     */
111    @Parameter(property = DockerCracMojo.CRAC_CHECKPOINT_TIMEOUT_PROPERTY, defaultValue = DockerCracMojo.DEFAULT_CRAC_CHECKPOINT_TIMEOUT)
112    private Integer checkpointTimeoutSeconds;
113
114    /**
115     * The name of the docker network to run the checkpoint container in.
116     */
117    @Parameter(property = DockerCracMojo.CRAC_CHECKPOINT_NETWORK_PROPERTY)
118    private String checkpointNetworkName;
119
120    /**
121     * The version of the Azul CRaC enabled JDK to use.
122     */
123    @Parameter(property = DockerCracMojo.CRAC_JAVA_VERSION, defaultValue = "${jdk.version}")
124    private String cracJavaVersion;
125
126    /**
127     * The architecture to use for the CRaC enabled JDK. Defaults to {@code os.arch}
128     */
129    @Parameter(property = DockerCracMojo.CRAC_ARCHITECTURE)
130    private String cracArchitecture;
131
132    /**
133     * The os to use for the CRaC enabled JDK.
134     */
135    @Parameter(property = DockerCracMojo.CRAC_OS, defaultValue = DEFAULT_CRAC_OS)
136    private String cracOs;
137
138    @SuppressWarnings("CdiInjectionPointsInspection")
139    @Inject
140    public DockerCracMojo(
141        MavenProject mavenProject,
142        JibConfigurationService jibConfigurationService,
143        ApplicationConfigurationService applicationConfigurationService,
144        DockerService dockerService,
145        MavenReaderFilter mavenReaderFilter,
146        MavenSession mavenSession,
147        MojoExecution mojoExecution
148    ) {
149        super(mavenProject, jibConfigurationService, applicationConfigurationService, dockerService, mavenSession, mojoExecution);
150        this.mavenReaderFilter = mavenReaderFilter;
151    }
152
153    @Override
154    public void execute() throws MojoExecutionException {
155        try {
156            copyDependencies();
157
158            MicronautRuntime runtime = MicronautRuntime.valueOf(micronautRuntime.toUpperCase());
159
160            switch (runtime.getBuildStrategy()) {
161                case LAMBDA -> throw new MojoExecutionException("Lambda Functions are currently unsupported");
162                case ORACLE_FUNCTION -> throw new MojoExecutionException("Oracle Functions are currently unsupported");
163                case DEFAULT -> buildDockerCrac();
164                default -> throw new IllegalStateException("Unexpected value: " + runtime.getBuildStrategy());
165            }
166        } catch (InvalidImageReferenceException iire) {
167            String message = "Invalid image reference "
168                + iire.getInvalidReference()
169                + ", perhaps you should check that the reference is formatted correctly according to " +
170                "https://docs.docker.com/engine/reference/commandline/tag/#extended-description" +
171                "\nFor example, slash-separated name components cannot have uppercase letters";
172            throw new MojoExecutionException(message);
173        } catch (IOException | IllegalArgumentException | MavenFilteringException e) {
174            throw new MojoExecutionException(e.getMessage(), e);
175        }
176    }
177
178    private void buildDockerCrac() throws IOException, InvalidImageReferenceException, MavenFilteringException {
179        String checkpointImage = buildCheckpointDockerfile();
180        getLog().info("CRaC Checkpoint image: " + checkpointImage);
181        File checkpointDir = new File(mavenProject.getBuild().getDirectory(), "cr");
182        // We need to make this folder first, or else Docker on linux will make it as root and break clean on CI
183        checkpointDir.mkdirs();
184        dockerService.runPrivilegedImageAndWait(
185            checkpointImage,
186            checkpointTimeoutSeconds,
187            checkpointNetworkName,
188            checkpointDir.getAbsolutePath() + ":/home/app/cr"
189        );
190        buildFinalDockerfile(checkpointImage);
191    }
192
193    private String limitArchitecture(String architecture) {
194        if (architecture == null) {
195            return null;
196        }
197        if (ARM_ARCH.equals(architecture)) {
198            return architecture;
199        }
200        return X86_64_ARCH;
201    }
202
203    private String buildCheckpointDockerfile() throws IOException, MavenFilteringException {
204        String name = mavenProject.getArtifactId() + "-crac-checkpoint";
205        var checkpointTags = Collections.singleton(name);
206        copyScripts(CHECKPOINT_SCRIPT_NAME, WARMUP_SCRIPT_NAME, RUN_SCRIPT_NAME);
207        File dockerfile = dockerService.loadDockerfileAsResource(DockerfileMojo.DOCKERFILE_CRAC_CHECKPOINT);
208
209        String systemArchitecture = limitArchitecture(System.getProperty("os.arch"));
210        String filteredCracArchitecture = limitArchitecture(cracArchitecture);
211        String finalArchitecture = filteredCracArchitecture == null ? systemArchitecture : filteredCracArchitecture;
212        String baseImage = getFromImage().orElse(DEFAULT_BASE_IMAGE);
213
214        getLog().info("Using BASE_IMAGE: " + baseImage);
215        getLog().info("Using CRAC_ARCH: " + finalArchitecture);
216        getLog().info("Using CRAC_JDK_VERSION: " + cracJavaVersion);
217        getLog().info("Using CRAC_OS: " + cracOs);
218
219        BuildImageCmd buildImageCmd = dockerService.buildImageCmd()
220            .withDockerfile(dockerfile)
221            .withBuildArg("BASE_IMAGE", baseImage)
222            .withBuildArg("CRAC_ARCH", finalArchitecture)
223            .withBuildArg("CRAC_OS", cracOs)
224            .withBuildArg("CRAC_JDK_VERSION", cracJavaVersion)
225            .withTags(checkpointTags);
226        getNetworkMode().ifPresent(buildImageCmd::withNetworkMode);
227        dockerService.buildImage(buildImageCmd);
228        return name;
229    }
230
231    private void buildFinalDockerfile(String checkpointContainerId) throws IOException, InvalidImageReferenceException, MavenFilteringException {
232        var tags = getTags();
233        for (String tag : tags) {
234            ImageReference.parse(tag);
235        }
236
237        String ports = getPorts();
238        getLog().info("Exposing port(s): " + ports);
239
240        copyScripts(RUN_SCRIPT_NAME);
241        File dockerfile = dockerService.loadDockerfileAsResource(DockerfileMojo.DOCKERFILE_CRAC);
242        String baseImage = getFromImage().orElse(DEFAULT_BASE_IMAGE);
243
244        getLog().info("Using BASE_IMAGE: " + baseImage);
245        getLog().info("Using CHECKPOINT_IMAGE: " + checkpointContainerId);
246
247        BuildImageCmd buildImageCmd = dockerService.buildImageCmd()
248            .withDockerfile(dockerfile)
249            .withBuildArg("PORTS", ports)
250            .withBuildArg("BASE_IMAGE", getFromImage().orElse(DEFAULT_BASE_IMAGE))
251            .withBuildArg("CHECKPOINT_IMAGE", checkpointContainerId)
252            .withTags(getTags());
253        getNetworkMode().ifPresent(buildImageCmd::withNetworkMode);
254        dockerService.buildImage(buildImageCmd);
255
256        getLog().warn("**********************************************************");
257        getLog().warn(" CRaC checkpoint files may contain sensitive information.");
258        getLog().warn("**********************************************************");
259    }
260
261    private Properties replacementProperties(String readinessCommand, String mainClass) {
262        Properties properties = new Properties();
263        properties.setProperty("READINESS", readinessCommand);
264        properties.setProperty("MAINCLASS", mainClass);
265        return properties;
266    }
267
268    private void copyScripts(String... scriptNames) throws IOException, MavenFilteringException {
269        var target = new File(mavenProject.getBuild().getDirectory(), "scripts");
270        if (!target.exists()) {
271            target.mkdirs();
272        }
273        processScripts(target, replacementProperties(readinessCommand, mainClass), scriptNames);
274    }
275
276    private void processScripts(File target, Properties replacements, String... scriptNames) throws IOException, MavenFilteringException {
277        for (String script : scriptNames) {
278            var localOverride = new File(mavenProject.getBasedir(), script);
279            InputStream resourceStream = DockerCracMojo.class.getResourceAsStream("/cracScripts/" + script);
280            Reader resourceReader = resourceStream == null ? null : new InputStreamReader(resourceStream);
281            try (Reader reader = localOverride.exists() ? Files.newBufferedReader(localOverride.toPath()) : resourceReader) {
282                if (reader == null) {
283                    throw new IOException("Could not find script " + script);
284                }
285                var req = new MavenReaderFilterRequest();
286                req.setFrom(reader);
287                req.setFiltering(true);
288                req.setAdditionalProperties(replacements);
289                Path outputPath = target.toPath().resolve(script);
290                try (Writer writer = Files.newBufferedWriter(outputPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
291                    IOUtils.copy(mavenReaderFilter.filter(req), writer);
292                }
293                Files.setPosixFilePermissions(outputPath, POSIX_FILE_PERMISSIONS);
294            }
295        }
296    }
297}