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 DockerClientConfig config;
077    private final MavenProject mavenProject;
078    private final JibConfigurationService jibConfigurationService;
079    private DockerClient dockerClient;
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    }
088
089    private DockerClient getDockerClient() {
090        if (dockerClient == null) {
091            var httpClient = new ZerodepDockerHttpClient.Builder()
092                    .dockerHost(config.getDockerHost())
093                    .sslConfig(config.getSSLConfig())
094                    .build();
095            dockerClient = DockerClientImpl.getInstance(config, httpClient);
096        }
097        return dockerClient;
098    }
099
100    /**
101     * @param dockerfileName the name of the Dockerfile to load
102     * @return the {@link BuildImageCmd} by loading the given Dockerfile as classpath resource.
103     */
104    public BuildImageCmd buildImageCmd(String dockerfileName) throws IOException {
105        verifyDockerRunning();
106        BuildImageCmd buildImageCmd = getDockerClient().buildImageCmd(loadDockerfileAsResource(dockerfileName));
107        maybeConfigureBuildAuth(buildImageCmd);
108        return buildImageCmd;
109    }
110
111    private void maybeConfigureBuildAuth(BuildImageCmd buildImageCmd) {
112        jibConfigurationService.getFromImage().ifPresent(image -> {
113            Optional<Credential> fromCredentials = jibConfigurationService.getFromCredentials();
114            Optional<Credential> credential = fromCredentials.or(() -> jibConfigurationService.resolveCredentialForImage(image, LOG));
115            credential.ifPresent(cred -> {
116                var username = cred.getUsername();
117                var password = cred.getPassword();
118                AuthConfig authConfig = getAuthConfigFor(image, username, password);
119                var authConfigurations = new AuthConfigurations();
120                authConfigurations.addConfig(authConfig);
121                buildImageCmd.withBuildAuthConfigs(authConfigurations);
122            });
123        });
124    }
125
126    /**
127     * @return a default {@link BuildImageCmd}.
128     */
129    public BuildImageCmd buildImageCmd() {
130        verifyDockerRunning();
131        BuildImageCmd buildImageCmd = getDockerClient().buildImageCmd();
132        maybeConfigureBuildAuth(buildImageCmd);
133        return buildImageCmd;
134    }
135
136    /**
137     * Builds the Docker image from the given {@link BuildImageCmd} builder.
138     *
139     * @param builder The builder to use.
140     * @return The resulting image ID.
141     */
142    public String buildImage(BuildImageCmd builder) {
143        verifyDockerRunning();
144        if (builder.getBuildArgs() != null) {
145            builder.getBuildArgs().forEach((k, v) -> LOG.info("Using {}: {}", k, v));
146        }
147        BuildImageResultCallback resultCallback = new BuildImageResultCallback() {
148            @Override
149            public void onNext(BuildResponseItem item) {
150                super.onNext(item);
151                if (item.isErrorIndicated() && item.getErrorDetail() != null) {
152                    LOG.error(item.getErrorDetail().getMessage());
153                } else if (item.getStream() != null) {
154                    String msg = StringUtils.removeEnd(item.getStream(), System.lineSeparator());
155                    LOG.info(msg);
156                }
157            }
158        };
159
160        return builder
161            .exec(resultCallback)
162            .awaitImageId();
163    }
164
165    /**
166     * Creates a container based on a given image, and runs it.
167     *
168     * @param imageId the image to use
169     * @param timeoutSeconds the timeout in seconds for the container to finish execution
170     * @param checkpointNetworkName the name of the network to use for the container
171     * @param binds the bind mounts to use
172     */
173    public void runPrivilegedImageAndWait(String imageId, Integer timeoutSeconds, String checkpointNetworkName, String... binds) throws IOException {
174        verifyDockerRunning();
175        try (CreateContainerCmd create = getDockerClient().createContainerCmd(imageId)) {
176            HostConfig hostConfig = create.getHostConfig();
177            if (hostConfig == null) {
178                throw new DockerClientException("When setting binds and privileged, hostConfig was null.  Please check your docker installation and try again");
179            }
180            hostConfig.withPrivileged(true);
181            if (checkpointNetworkName != null) {
182                hostConfig.withNetworkMode(checkpointNetworkName);
183            }
184            for (String bind : binds) {
185                hostConfig.withBinds(Bind.parse(bind));
186            }
187            CreateContainerResponse createResponse = create.exec();
188            try (StartContainerCmd start = getDockerClient().startContainerCmd(createResponse.getId())) {
189                start.exec();
190                LOG.info("Container started: {} {}", createResponse.getId(), start.getContainerId());
191                try (WaitContainerCmd wait = getDockerClient().waitContainerCmd(createResponse.getId())) {
192                    WaitContainerResultCallback waitResult = wait.start();
193                    LOG.info("Waiting {} seconds for completion", timeoutSeconds);
194                    Integer exitCode = waitResult.awaitStatusCode(timeoutSeconds, TimeUnit.SECONDS);
195                    if (exitCode != 0) {
196                        final Slf4jLogConsumer stdoutConsumer = new Slf4jLogConsumer(LOG);
197                        final Slf4jLogConsumer stderrConsumer = new Slf4jLogConsumer(LOG);
198
199                        try (var callback = new FrameConsumerResultCallback()) {
200                            callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer);
201                            callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer);
202
203                            getDockerClient().logContainerCmd(start.getContainerId())
204                                .withStdOut(true)
205                                .withStdErr(true)
206                                .exec(callback)
207                                .awaitCompletion();
208                        } catch (InterruptedException e) {
209                            Thread.currentThread().interrupt();
210                        }
211                        throw new IOException("Image " + imageId + " exited with code " + exitCode);
212                    }
213                }
214            }
215        }
216    }
217
218    /**
219     * Copies a file from the specified container path in the given image ID, into a temporal location.
220     *
221     * @param imageId The image ID.
222     * @param containerPath The container path.
223     * @return The temporal file.
224     */
225    public File copyFromContainer(String imageId, String containerPath) {
226        CreateContainerCmd containerCmd = getDockerClient().createContainerCmd(imageId);
227        CreateContainerResponse container = containerCmd.exec();
228        getDockerClient().startContainerCmd(container.getId());
229        InputStream nativeImage = getDockerClient().copyArchiveFromContainerCmd(container.getId(), containerPath).exec();
230
231        try (var fin = new TarArchiveInputStream(nativeImage)) {
232            TarArchiveEntry tarEntry = fin.getNextEntry();
233            File file = new File(mavenProject.getBuild().getDirectory(), tarEntry.getName());
234            if (!file.getCanonicalFile().toPath().startsWith(mavenProject.getBuild().getDirectory())) {
235                throw new IOException("Entry is outside of the target directory");
236            }
237
238            IOUtils.copy(fin, Files.newOutputStream(file.toPath()));
239
240            return file;
241        } catch (IOException e) {
242            LOG.error("Failed to copy file from container", e);
243        } finally {
244            containerCmd.close();
245        }
246        return null;
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     * @return the file where the Dockerfile was copied to.
254     */
255    public File loadDockerfileAsResource(String name) throws IOException {
256        return loadDockerfileAsResource(name, DockerfileMojo.DOCKERFILE);
257    }
258
259    /**
260     * Loads the given Dockerfile as classpath resource and copies it into a temporary location in the target directory.
261     *
262     * @param name the name of the Dockerfile.
263     * @param targetFileName the name of the file to copy the Dockerfile to.
264     * @return the file where the Dockerfile was copied to.
265     */
266    public File loadDockerfileAsResource(String name, String targetFileName) throws IOException {
267        String path = "/dockerfiles/" + name;
268        InputStream stream = getClass().getResourceAsStream(path);
269        if (stream != null) {
270            var dockerfile = new File(mavenProject.getBuild().getDirectory(), targetFileName);
271            FileUtils.copyInputStreamToFile(stream, dockerfile);
272            return dockerfile;
273        }
274        return null;
275    }
276
277    /**
278     * @param imageName the image name
279     * @return a {@link PushImageCmd} from the given image name.
280     */
281    public PushImageCmd pushImageCmd(String imageName) {
282        verifyDockerRunning();
283        return getDockerClient().pushImageCmd(imageName);
284    }
285
286    /**
287     * @param dockerImage the image name
288     * @param username the username
289     * @param password the password
290     * @return an {@link AuthConfig} object for the given image, username and password.
291     */
292    public AuthConfig getAuthConfigFor(String dockerImage, String username, String password) {
293        DockerImageName dockerImageName = DockerImageName.parse(dockerImage);
294        var defaultAuthConfig = new AuthConfig()
295            .withRegistryAddress(dockerImageName.getRegistry())
296            .withUsername(username)
297            .withPassword(password);
298        RegistryAuthLocator registryAuthLocator = RegistryAuthLocator.instance();
299        AuthConfig authConfig = registryAuthLocator.lookupAuthConfig(dockerImageName, defaultAuthConfig);
300        boolean loginSucceeded = false;
301        try {
302            AuthResponse authResponse = getDockerClient().authCmd().withAuthConfig(authConfig).exec();
303            if (authResponse.getStatus() != null && authResponse.getStatus().equals("Login Succeeded")) {
304                loginSucceeded = true;
305            }
306        } catch (Exception ignored) {
307            // typically this is com.github.dockerjava.api.exception.UnauthorizedException
308        }
309
310        if (loginSucceeded) {
311            LOG.info("Successfully logged in to registry {}", dockerImageName.getRegistry());
312        } else {
313            LOG.warn("Failed to login to registry {}", dockerImageName.getRegistry());
314        }
315        return authConfig;
316    }
317
318    private void verifyDockerRunning() {
319        try {
320            getDockerClient().pingCmd().exec();
321        } catch (DockerException e) {
322            throw new IllegalStateException(e.getMessage());
323        } catch (RuntimeException e) {
324            throw new IllegalStateException("Cannot connect to the Docker daemon at " + config.getDockerHost() + ". Is the docker daemon running?", e);
325        }
326    }
327}