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}