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}