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