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.services;
017
018import com.github.dockerjava.api.DockerClient;
019import com.github.dockerjava.api.command.BuildImageCmd;
020import com.github.dockerjava.api.command.BuildImageResultCallback;
021import com.github.dockerjava.api.command.CreateContainerCmd;
022import com.github.dockerjava.api.command.CreateContainerResponse;
023import com.github.dockerjava.api.command.PushImageCmd;
024import com.github.dockerjava.api.command.StartContainerCmd;
025import com.github.dockerjava.api.command.WaitContainerCmd;
026import com.github.dockerjava.api.command.WaitContainerResultCallback;
027import com.github.dockerjava.api.exception.DockerClientException;
028import com.github.dockerjava.api.exception.DockerException;
029import com.github.dockerjava.api.model.AuthConfig;
030import com.github.dockerjava.api.model.AuthConfigurations;
031import com.github.dockerjava.api.model.AuthResponse;
032import com.github.dockerjava.api.model.Bind;
033import com.github.dockerjava.api.model.BuildResponseItem;
034import com.github.dockerjava.api.model.HostConfig;
035import com.github.dockerjava.core.DefaultDockerClientConfig;
036import com.github.dockerjava.core.DockerClientConfig;
037import com.github.dockerjava.core.DockerClientImpl;
038import com.github.dockerjava.zerodep.ZerodepDockerHttpClient;
039import com.google.cloud.tools.jib.api.Credential;
040import io.micronaut.maven.DockerfileMojo;
041import io.micronaut.maven.jib.JibConfigurationService;
042import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
043import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
044import org.apache.commons.io.FileUtils;
045import org.apache.commons.io.IOUtils;
046import org.apache.commons.lang3.StringUtils;
047import org.apache.maven.project.MavenProject;
048import org.slf4j.Logger;
049import org.slf4j.LoggerFactory;
050import org.testcontainers.containers.output.FrameConsumerResultCallback;
051import org.testcontainers.containers.output.OutputFrame;
052import org.testcontainers.containers.output.Slf4jLogConsumer;
053import org.testcontainers.utility.DockerImageName;
054import org.testcontainers.utility.RegistryAuthLocator;
055
056import javax.inject.Inject;
057import javax.inject.Singleton;
058import java.io.File;
059import java.io.IOException;
060import java.io.InputStream;
061import java.nio.file.Files;
062import java.util.Optional;
063import java.util.concurrent.TimeUnit;
064
065/**
066 * Provides methods to work with Docker images.
067 *
068 * @author Álvaro Sánchez-Mariscal
069 * @since 1.1
070 */
071@Singleton
072public class DockerService {
073
074    private static final Logger LOG = LoggerFactory.getLogger(DockerService.class);
075
076    private final DockerClient dockerClient;
077    private final DockerClientConfig config;
078    private final MavenProject mavenProject;
079    private final JibConfigurationService jibConfigurationService;
080
081    @SuppressWarnings("CdiInjectionPointsInspection")
082    @Inject
083    public DockerService(MavenProject mavenProject, JibConfigurationService jibConfigurationService) {
084        this.mavenProject = mavenProject;
085        this.jibConfigurationService = jibConfigurationService;
086        this.config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
087        var httpClient = new ZerodepDockerHttpClient.Builder()
088            .dockerHost(config.getDockerHost())
089            .sslConfig(config.getSSLConfig())
090            .build();
091        dockerClient = DockerClientImpl.getInstance(config, httpClient);
092    }
093
094    /**
095     * @param dockerfileName the name of the Dockerfile to load
096     * @return the {@link BuildImageCmd} by loading the given Dockerfile as classpath resource.
097     */
098    public BuildImageCmd buildImageCmd(String dockerfileName) throws IOException {
099        verifyDockerRunning();
100        BuildImageCmd buildImageCmd = dockerClient.buildImageCmd(loadDockerfileAsResource(dockerfileName));
101        maybeConfigureBuildAuth(buildImageCmd);
102        return buildImageCmd;
103    }
104
105    private void maybeConfigureBuildAuth(BuildImageCmd buildImageCmd) {
106        Optional<String> fromImage = jibConfigurationService.getFromImage();
107        Optional<Credential> fromCredentials = jibConfigurationService.getFromCredentials();
108        if (fromImage.isPresent() && fromCredentials.isPresent()) {
109            AuthConfig authConfig = getAuthConfigFor(fromImage.get(), fromCredentials.get().getUsername(), fromCredentials.get().getPassword());
110            var authConfigurations = new AuthConfigurations();
111            authConfigurations.addConfig(authConfig);
112            buildImageCmd.withBuildAuthConfigs(authConfigurations);
113        }
114    }
115
116    /**
117     * @return a default {@link BuildImageCmd}.
118     */
119    public BuildImageCmd buildImageCmd() {
120        verifyDockerRunning();
121        BuildImageCmd buildImageCmd = dockerClient.buildImageCmd();
122        maybeConfigureBuildAuth(buildImageCmd);
123        return buildImageCmd;
124    }
125
126    /**
127     * Builds the Docker image from the given {@link BuildImageCmd} builder.
128     *
129     * @param builder The builder to use.
130     * @return The resulting image ID.
131     */
132    public String buildImage(BuildImageCmd builder) {
133        verifyDockerRunning();
134        if (builder.getBuildArgs() != null) {
135            builder.getBuildArgs().forEach((k, v) -> LOG.info("Using {}: {}", k, v));
136        }
137        BuildImageResultCallback resultCallback = new BuildImageResultCallback() {
138            @Override
139            public void onNext(BuildResponseItem item) {
140                super.onNext(item);
141                if (item.isErrorIndicated() && item.getErrorDetail() != null) {
142                    LOG.error(item.getErrorDetail().getMessage());
143                } else if (item.getStream() != null) {
144                    String msg = StringUtils.removeEnd(item.getStream(), System.lineSeparator());
145                    LOG.info(msg);
146                }
147            }
148        };
149
150        return builder
151            .exec(resultCallback)
152            .awaitImageId();
153    }
154
155    /**
156     * Creates a container based on a given image, and runs it.
157     *
158     * @param imageId the image to use
159     * @param timeoutSeconds the timeout in seconds for the container to finish execution
160     * @param checkpointNetworkName the name of the network to use for the container
161     * @param binds the bind mounts to use
162     */
163    public void runPrivilegedImageAndWait(String imageId, Integer timeoutSeconds, String checkpointNetworkName, String... binds) throws IOException {
164        verifyDockerRunning();
165        try (CreateContainerCmd create = dockerClient.createContainerCmd(imageId)) {
166            HostConfig hostConfig = create.getHostConfig();
167            if (hostConfig == null) {
168                throw new DockerClientException("When setting binds and privileged, hostConfig was null.  Please check your docker installation and try again");
169            }
170            hostConfig.withPrivileged(true);
171            if (checkpointNetworkName != null) {
172                hostConfig.withNetworkMode(checkpointNetworkName);
173            }
174            for (String bind : binds) {
175                hostConfig.withBinds(Bind.parse(bind));
176            }
177            CreateContainerResponse createResponse = create.exec();
178            try (StartContainerCmd start = dockerClient.startContainerCmd(createResponse.getId())) {
179                start.exec();
180                LOG.info("Container started: {} {}", createResponse.getId(), start.getContainerId());
181                try (WaitContainerCmd wait = dockerClient.waitContainerCmd(createResponse.getId())) {
182                    WaitContainerResultCallback waitResult = wait.start();
183                    LOG.info("Waiting {} seconds for completion", timeoutSeconds);
184                    Integer exitCode = waitResult.awaitStatusCode(timeoutSeconds, TimeUnit.SECONDS);
185                    if (exitCode != 0) {
186                        final Slf4jLogConsumer stdoutConsumer = new Slf4jLogConsumer(LOG);
187                        final Slf4jLogConsumer stderrConsumer = new Slf4jLogConsumer(LOG);
188
189                        try (var callback = new FrameConsumerResultCallback()) {
190                            callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer);
191                            callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer);
192
193                            dockerClient.logContainerCmd(start.getContainerId())
194                                .withStdOut(true)
195                                .withStdErr(true)
196                                .exec(callback)
197                                .awaitCompletion();
198                        } catch (InterruptedException e) {
199                            Thread.currentThread().interrupt();
200                        }
201                        throw new IOException("Image " + imageId + " exited with code " + exitCode);
202                    }
203                }
204            }
205        }
206    }
207
208    /**
209     * Copies a file from the specified container path in the given image ID, into a temporal location.
210     *
211     * @param imageId The image ID.
212     * @param containerPath The container path.
213     * @return The temporal file.
214     */
215    public File copyFromContainer(String imageId, String containerPath) {
216        CreateContainerCmd containerCmd = dockerClient.createContainerCmd(imageId);
217        CreateContainerResponse container = containerCmd.exec();
218        dockerClient.startContainerCmd(container.getId());
219        InputStream nativeImage = dockerClient.copyArchiveFromContainerCmd(container.getId(), containerPath).exec();
220
221        try (var fin = new TarArchiveInputStream(nativeImage)) {
222            TarArchiveEntry tarEntry = fin.getNextEntry();
223            File file = new File(mavenProject.getBuild().getDirectory(), tarEntry.getName());
224            if (!file.getCanonicalFile().toPath().startsWith(mavenProject.getBuild().getDirectory())) {
225                throw new IOException("Entry is outside of the target directory");
226            }
227
228            IOUtils.copy(fin, Files.newOutputStream(file.toPath()));
229
230            return file;
231        } catch (IOException e) {
232            LOG.error("Failed to copy file from container", e);
233        } finally {
234            containerCmd.close();
235        }
236        return null;
237    }
238
239    /**
240     * Loads the given Dockerfile as classpath resource and copies it into a temporary location in the target directory.
241     *
242     * @param name the name of the Dockerfile.
243     * @return the file where the Dockerfile was copied to.
244     */
245    public File loadDockerfileAsResource(String name) throws IOException {
246        return loadDockerfileAsResource(name, DockerfileMojo.DOCKERFILE);
247    }
248
249    /**
250     * Loads the given Dockerfile as classpath resource and copies it into a temporary location in the target directory.
251     *
252     * @param name the name of the Dockerfile.
253     * @param targetFileName the name of the file to copy the Dockerfile to.
254     * @return the file where the Dockerfile was copied to.
255     */
256    public File loadDockerfileAsResource(String name, String targetFileName) throws IOException {
257        String path = "/dockerfiles/" + name;
258        InputStream stream = getClass().getResourceAsStream(path);
259        if (stream != null) {
260            var dockerfile = new File(mavenProject.getBuild().getDirectory(), targetFileName);
261            FileUtils.copyInputStreamToFile(stream, dockerfile);
262            return dockerfile;
263        }
264        return null;
265    }
266
267    /**
268     * @param imageName the image name
269     * @return a {@link PushImageCmd} from the given image name.
270     */
271    public PushImageCmd pushImageCmd(String imageName) {
272        verifyDockerRunning();
273        return dockerClient.pushImageCmd(imageName);
274    }
275
276    /**
277     * @param dockerImage the image name
278     * @param username the username
279     * @param password the password
280     * @return an {@link AuthConfig} object for the given image, username and password.
281     */
282    public AuthConfig getAuthConfigFor(String dockerImage, String username, String password) {
283        DockerImageName dockerImageName = DockerImageName.parse(dockerImage);
284        var defaultAuthConfig = new AuthConfig()
285            .withRegistryAddress(dockerImageName.getRegistry())
286            .withUsername(username)
287            .withPassword(password);
288        RegistryAuthLocator registryAuthLocator = RegistryAuthLocator.instance();
289        AuthConfig authConfig = registryAuthLocator.lookupAuthConfig(dockerImageName, defaultAuthConfig);
290        boolean loginSucceeded = false;
291        try {
292            AuthResponse authResponse = dockerClient.authCmd().withAuthConfig(authConfig).exec();
293            if (authResponse.getStatus() != null && authResponse.getStatus().equals("Login Succeeded")) {
294                loginSucceeded = true;
295            }
296        } catch (Exception ignored) {
297            // typically this is com.github.dockerjava.api.exception.UnauthorizedException
298        }
299
300        if (loginSucceeded) {
301            LOG.info("Successfully logged in to registry {}", dockerImageName.getRegistry());
302        } else {
303            LOG.warn("Failed to login to registry {}", dockerImageName.getRegistry());
304        }
305        return authConfig;
306    }
307
308    private void verifyDockerRunning() {
309        try {
310            dockerClient.pingCmd().exec();
311        } catch (DockerException e) {
312            throw new IllegalStateException(e.getMessage());
313        } catch (RuntimeException e) {
314            throw new IllegalStateException("Cannot connect to the Docker daemon at " + config.getDockerHost() + ". Is the docker daemon running?", e);
315        }
316    }
317}