/*- * -\-\- * Dockerfile Maven Plugin * -- * Copyright (C) 2016 Spotify AB * -- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * -/-/- */ package com.spotify.plugin.dockerfile; import com.google.auth.oauth2.GoogleCredentials; import com.google.common.base.Charsets; import com.google.common.base.Preconditions; import com.google.common.io.Files; import com.spotify.docker.client.DefaultDockerClient; import com.spotify.docker.client.DockerClient; import com.spotify.docker.client.DockerConfigReader; import com.spotify.docker.client.auth.ConfigFileRegistryAuthSupplier; import com.spotify.docker.client.auth.MultiRegistryAuthSupplier; import com.spotify.docker.client.auth.RegistryAuthSupplier; import com.spotify.docker.client.auth.gcr.ContainerRegistryAuthSupplier; import com.spotify.docker.client.exceptions.DockerCertificateException; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.List; import java.util.Objects; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.apache.maven.archiver.MavenArchiveConfiguration; import org.apache.maven.archiver.MavenArchiver; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.logging.Log; import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProject; import org.apache.maven.project.MavenProjectHelper; import org.apache.maven.settings.crypto.SettingsDecrypter; import org.codehaus.plexus.archiver.Archiver; import org.codehaus.plexus.archiver.jar.JarArchiver; public abstract class AbstractDockerMojo extends AbstractMojo { protected enum Metadata { IMAGE_ID("image ID", "image-id"), REPOSITORY("repository", "repository"), TAG("tag", "tag"), IMAGE_NAME("image name", "image-name"); private final String friendlyName; private final String fileName; Metadata(String friendlyName, String fileName) { this.friendlyName = friendlyName; this.fileName = fileName; } public String getFriendlyName() { return friendlyName; } public String getFileName() { return fileName; } } /** * Directory containing the generated Docker info JAR. */ @Parameter(defaultValue = "${project.build.directory}", property = "dockerfile.outputDirectory", required = true) private File buildDirectory; /** * Directory where various Docker-related metadata fragments will be stored. */ @Parameter(defaultValue = "${project.build.directory}/docker", property = "dockerfile.dockerInfoDirectory", required = true) protected File dockerInfoDirectory; /** * Path to docker config file, if the default is not acceptable. */ @Parameter(property = "dockerfile.dockerConfigFile") protected File dockerConfigFile; /** * A maven server id, in order to use maven settings to supply server auth. */ @Parameter(defaultValue = "false", property = "dockerfile.useMavenSettingsForAuth") protected boolean useMavenSettingsForAuth; /** * Whether to connect to Docker Daemon using HTTP proxy, if set. */ @Parameter(defaultValue = "true", property = "dockerfile.useProxy") protected boolean useProxy; /** * Directory where test metadata will be written during build. */ @Parameter(defaultValue = "${project.build.testOutputDirectory}", property = "dockerfile.testOutputDirectory", required = true) protected File testOutputDirectory; @Parameter(defaultValue = "300000" /* 5 minutes */, property = "dockerfile.readTimeoutMillis", required = true) protected long readTimeoutMillis; @Parameter(defaultValue = "300000" /* 5 minutes */, property = "dockerfile.connectTimeoutMillis", required = true) protected long connectTimeoutMillis; /** * Certain Docker operations can fail due to mysterious Docker daemon conditions. Sometimes it * might be worth it to just retry operations until they succeed. This parameter controls how * many times operations should be retried before they fail. By default, an extra attempt (so up * to two attempts) is made before failing. */ @Parameter(defaultValue = "1", property = "dockerfile.retryCount") protected int retryCount; @Parameter(property = "dockerfile.username") protected String username; @Parameter(property = "dockerfile.password") protected String password; /** * Whether to output a verbose log when performing various operations. */ @Parameter(defaultValue = "false", property = "dockerfile.verbose") protected boolean verbose; /** * Disables the entire dockerfile plugin; all goals become no-ops. */ @Parameter(defaultValue = "false", property = "dockerfile.skip") protected boolean skip; /** * Whether to write image information into the test output directory, so that docker information * is available on the CLASSPATH for integration tests. */ @Parameter(defaultValue = "true", property = "dockerfile.writeTestMetadata") protected boolean writeTestMetadata; /** * Require the jar plugin to build a new Docker info JAR even if none of the contents appear to * have changed. By default, this plugin looks to see if the output jar exists and inputs have * not changed. If these conditions are true, the plugin skips creation of the jar. This does * not work when other plugins, like the maven-shade-plugin, are configured to post-process the * jar. This plugin can not detect the post-processing, and so leaves the post-processed jar in * place. This can lead to failures when those plugins do not expect to find their own output as * an input. Set this parameter to <tt>true</tt> to avoid these problems by forcing this plugin * to recreate the jar every time. */ @Parameter(defaultValue = "false", property = "dockerfile.forceCreation") private boolean forceCreation; /** * Name of the generated Docker info JAR. */ @Parameter(defaultValue = "${project.build.finalName}", property = "dockerfile.finalName") private String finalName; /** * Classifier to use when attaching the Docker info JAR. If empty or absent, the JAR will become * the main artifact of the project. */ @Parameter(defaultValue = "docker-info", property = "dockerfile.classifier") protected String classifier; /** * Skip creation of the Docker info JAR. */ @Parameter(defaultValue = "false", property = "dockerfile.skipDockerInfo") protected boolean skipDockerInfo; /** * The Maven project. */ @Parameter(defaultValue = "${project}", readonly = true, required = true) protected MavenProject project; /** * The current Maven session. */ @Parameter(defaultValue = "${session}", readonly = true, required = true) private MavenSession session; /** * The archive configuration to use for the Docker info JAR. This can be used to embed additional * information in the JAR. */ @Parameter private MavenArchiveConfiguration archive = new MavenArchiveConfiguration(); /** * The JAR archiver. */ @Component(role = Archiver.class, hint = "jar") private JarArchiver jarArchiver; /** * Allows disabling of Google Container Registry authentication support. The support is enabled by * default, and should be a no-op (and fail fast) in most non-GCR environments, but this behavior * can be explicitly disabled with this property if needed. */ @Parameter(defaultValue = "true", property = "dockerfile.googleContainerRegistryEnabled") private boolean googleContainerRegistryEnabled; /** * The Maven project helper. */ @Component private MavenProjectHelper projectHelper; /** * The settings decrypter. */ @Component private SettingsDecrypter settingsDecrypter; protected abstract void execute(DockerClient dockerClient) throws MojoExecutionException, MojoFailureException; @Override public void execute() throws MojoExecutionException, MojoFailureException { if (skip) { getLog().info("Skipping execution because 'dockerfile.skip' is set"); } else { tryExecute(this.retryCount + 1); // We want to try at least one time } } private void tryExecute(int attempts) throws MojoFailureException, MojoExecutionException { Preconditions.checkArgument(attempts > 0, "attempts must not be negative"); MojoExecutionException exception = null; for (int attempt = 0; attempt < attempts; attempt++) { try { execute(openDockerClient()); return; // Not "break;" since we don't want to "throw exception;" } catch (MojoExecutionException e) { // Don't catch MojoFailureException, since that exception means "permanent failure" exception = e; final int attemptsLeft = attempts - attempt - 1; if (attemptsLeft > 0) { final String warningMessage = MessageFormat.format("An attempt failed, will retry {0} more times", attemptsLeft); getLog().warn(warningMessage, e); } } } throw exception; } protected void writeMetadata(Log log) throws MojoExecutionException { writeTestMetadata(); if (skipDockerInfo) { return; } final File jarFile = buildDockerInfoJar(log); attachJar(jarFile); } protected void writeMetadata(@Nonnull Metadata metadata, @Nonnull String value) throws MojoExecutionException { final File metadataFile = ensureMetadataFile(metadata); final String oldValue = readMetadata(metadata); if (Objects.equals(oldValue, value)) { return; } try { Files.write(value + "\n", metadataFile, Charsets.UTF_8); } catch (IOException e) { final String message = MessageFormat.format("Could not write {0} file at {1}", metadata.getFriendlyName(), metadataFile); throw new MojoExecutionException(message, e); } } private void writeTestMetadata() throws MojoExecutionException { if (writeTestMetadata && dockerInfoDirectory.exists()) { final File testMetadataDir = new File(testOutputDirectory, getMetaSubdir()); if (!testMetadataDir.isDirectory()) { if (!testMetadataDir.mkdirs()) { throw new MojoExecutionException("Could not create metadata output directory"); } } for (String name : dockerInfoDirectory.list()) { final File sourceFile = new File(dockerInfoDirectory, name); final File targetFile = new File(testMetadataDir, name); try { Files.copy(sourceFile, targetFile); } catch (IOException e) { throw new MojoExecutionException("Could not copy files", e); } } } } private String getMetaSubdir() { return String.format("META-INF/docker/%s/%s/", project.getGroupId(), project.getArtifactId()); } void attachJar(@Nonnull File jarFile) { if (classifier != null) { projectHelper.attachArtifact(project, "docker-info", classifier, jarFile); } else { project.getArtifact().setFile(jarFile); } } @Nonnull protected File buildDockerInfoJar(@Nonnull Log log) throws MojoExecutionException { final File jarFile = getJarFile(buildDirectory, finalName, classifier); final MavenArchiver archiver = new MavenArchiver(); archiver.setArchiver(jarArchiver); archiver.setOutputFile(jarFile); archive.setForced(forceCreation); if (dockerInfoDirectory.exists()) { final String prefix = getMetaSubdir(); archiver.getArchiver().addDirectory(dockerInfoDirectory, prefix); } else { log.warn("Docker info directory not created - Docker info JAR will be empty"); } try { archiver.createArchive(session, project, archive); } catch (Exception e) { throw new MojoExecutionException("Could not build Docker info JAR", e); } return jarFile; } @Nonnull private static File getJarFile(@Nonnull File basedir, @Nonnull String finalName, @Nullable String classifier) { if (classifier == null) { classifier = ""; } else { classifier = classifier.trim(); } if (classifier.length() > 0 && !classifier.startsWith("-")) { classifier = "-" + classifier; } return new File(basedir, finalName + classifier + ".jar"); } @Nonnull protected File ensureDockerInfoDirectory() throws MojoExecutionException { if (!dockerInfoDirectory.exists()) { if (!dockerInfoDirectory.mkdirs()) { throw new MojoExecutionException( MessageFormat .format("Could not create Docker info directory {0}", dockerInfoDirectory)); } } return dockerInfoDirectory; } @Nonnull protected File ensureMetadataFile(@Nonnull Metadata metadata) throws MojoExecutionException { return new File(ensureDockerInfoDirectory(), metadata.getFileName()); } protected void writeImageInfo(String repository, String tag) throws MojoExecutionException { writeMetadata(Metadata.REPOSITORY, repository); writeMetadata(Metadata.TAG, tag); writeMetadata(Metadata.IMAGE_NAME, formatImageName(repository, tag)); } @Nullable protected String readMetadata(@Nonnull Metadata metadata) throws MojoExecutionException { final File metadataFile = ensureMetadataFile(metadata); if (!metadataFile.exists()) { return null; } try { return Files.readFirstLine(metadataFile, Charsets.UTF_8); } catch (IOException e) { final String message = MessageFormat.format("Could not read {0} file at {1}", metadata.getFileName(), metadataFile); throw new MojoExecutionException(message, e); } } @Nonnull protected static String formatImageName(@Nonnull String repository, @Nonnull String tag) { return repository + ":" + tag; } @Nonnull private DockerClient openDockerClient() throws MojoExecutionException { final RegistryAuthSupplier authSupplier = createRegistryAuthSupplier(); try { return DefaultDockerClient.fromEnv() .readTimeoutMillis(readTimeoutMillis) .connectTimeoutMillis(connectTimeoutMillis) .registryAuthSupplier(authSupplier) .useProxy(useProxy) .build(); } catch (DockerCertificateException e) { throw new MojoExecutionException("Could not load Docker certificates", e); } } @Nonnull private RegistryAuthSupplier createRegistryAuthSupplier() { final List<RegistryAuthSupplier> suppliers = new ArrayList<>(); if (useMavenSettingsForAuth) { suppliers.add(new MavenRegistryAuthSupplier(session.getSettings(), settingsDecrypter)); } if (dockerConfigFile == null || "".equals(dockerConfigFile.getName())) { suppliers.add(new ConfigFileRegistryAuthSupplier()); } else { suppliers.add( new ConfigFileRegistryAuthSupplier( new DockerConfigReader(), dockerConfigFile.toPath() ) ); } if (googleContainerRegistryEnabled) { try { final RegistryAuthSupplier googleSupplier = googleContainerRegistryAuthSupplier(); if (googleSupplier != null) { suppliers.add(0, googleSupplier); } } catch (IOException ex) { getLog().info("Ignoring exception while loading Google credentials", ex); } } else { getLog().info("Google Container Registry support is disabled"); } MavenPomAuthSupplier pomSupplier = new MavenPomAuthSupplier(this.username, this.password); if (pomSupplier.hasUserName()) { suppliers.add(pomSupplier); } return new MultiRegistryAuthSupplier(suppliers); } /** * Attempt to load a GCR compatible RegistryAuthSupplier based on a few conditions: * <ol> * <li>First check to see if the environemnt variable DOCKER_GOOGLE_CREDENTIALS is set and points * to a readable file</li> * <li>Otherwise check if the Google Application Default Credentials can be loaded</li> * </ol> * Note that we use a special environment variable of our own in addition to any environment * variable that the ADC loading uses (GOOGLE_APPLICATION_CREDENTIALS) in case there is a need for * the user to use the latter env var for some other purpose in their build. * * @return a GCR RegistryAuthSupplier, or null * @throws IOException if an IOException occurs while loading the credentials */ @Nullable private RegistryAuthSupplier googleContainerRegistryAuthSupplier() throws IOException { GoogleCredentials credentials = null; final String googleCredentialsPath = System.getenv("DOCKER_GOOGLE_CREDENTIALS"); if (googleCredentialsPath != null) { final File file = new File(googleCredentialsPath); if (file.exists()) { try (FileInputStream inputStream = new FileInputStream(file)) { credentials = GoogleCredentials.fromStream(inputStream); getLog().info("Using Google credentials from file: " + file.getAbsolutePath()); } } } // use the ADC last if (credentials == null) { try { credentials = GoogleCredentials.getApplicationDefault(); getLog().info("Using Google application default credentials"); } catch (IOException ex) { // No GCP default credentials available getLog().debug("Failed to load Google application default credentials", ex); } } if (credentials == null) { return null; } return ContainerRegistryAuthSupplier.forCredentials(credentials).build(); } }