diff --git a/CHANGELOG.md b/CHANGELOG.md index 18348389c..a81dc62c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 8.6.0 (not yet released) + +### Revamped support for authentication +This version introduces a new way to configure DefaultDockerClient to use +authentication - the RegistryAuthSupplier interface. + +Historically, a single RegistryAuth instance was configured in +DefaultDockerClient at construction-time and the instance would be used +throughout the lifetime of the DefaultDockerClient instance. Many of the static +factory methods in the RegistryAuth class would use the first auth element +found in the docker client config file, and a DefaultDockerClient configured +with `dockerAuth(true)` would have this behavior enabled by default. + +Inspired by a desire to be able to integrate with pushing and pulling images to +Google Container Registry (where the docker client config file contains +short-lived access tokens), the previous behavior has been removed and is +replaced by RegistryAuthSupplier. DefaultDockerClient will now invoke the +appropriate method on the configured RegistryAuthSupplier instance before each +API operation that requires authentication. This allows for use of +authentication info that is dynamic and changes during the lifetime of the +DefaultDockerClient instance. + +The docker-client library contains an implementation of this interface that +returns static RegistryAuth instances (NoOpRegistryAuthSupplier, which is +configured for you if you use the old method `registryAuth(RegistryAuth)` in +DefaultDockerClient.Builder) and an implementation for refreshing GCR access +tokens with `gcloud docker -a`. We suggest that users implement this interface +themselves if there is a need to customize the behavior. + +The new class DockerConfigReader replaces the static factory methods from +RegistryAuth. + +The following methods are deprecated and will be removed in a future release: +- DefaultDockerClient.Builder.registryAuth(RegistryAuth) +- all overloads of RegistryAuth.fromDockerConfig(...) + +[740](https://github.com/spotify/docker-client/issues/740), [759](https://github.com/spotify/docker-client/pull/759) + ## 8.5.0 (released May 18 2017) ### Removal of deprecated methods diff --git a/pom.xml b/pom.xml index 4b3ed640c..8b5470b70 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ docker-client - 8.5.1-SNAPSHOT + 8.6.0-SNAPSHOT jar docker-client A docker client @@ -121,6 +121,11 @@ commons-compress 1.9 + + commons-io + commons-io + 2.5 + org.apache.httpcomponents httpclient diff --git a/src/main/java/com/spotify/docker/client/DefaultDockerClient.java b/src/main/java/com/spotify/docker/client/DefaultDockerClient.java index 8393402dc..054d3d5cf 100644 --- a/src/main/java/com/spotify/docker/client/DefaultDockerClient.java +++ b/src/main/java/com/spotify/docker/client/DefaultDockerClient.java @@ -47,6 +47,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.google.common.base.MoreObjects; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.base.Strings; @@ -97,6 +98,7 @@ import com.spotify.docker.client.messages.NetworkCreation; import com.spotify.docker.client.messages.ProgressMessage; import com.spotify.docker.client.messages.RegistryAuth; +import com.spotify.docker.client.messages.RegistryAuthSupplier; import com.spotify.docker.client.messages.RegistryConfigs; import com.spotify.docker.client.messages.RemovedImage; import com.spotify.docker.client.messages.ServiceCreateResponse; @@ -324,7 +326,7 @@ public void progress(ProgressMessage message) throws DockerException { private final URI uri; private final String apiVersion; - private final RegistryAuth registryAuth; + private final RegistryAuthSupplier registryAuthSupplier; private final Map headers; @@ -398,7 +400,12 @@ protected DefaultDockerClient(final Builder builder) { .property(ApacheClientProperties.CONNECTION_MANAGER, cm) .property(ApacheClientProperties.REQUEST_CONFIG, requestConfig); - this.registryAuth = builder.registryAuth; + + if (builder.registryAuthSupplier == null) { + this.registryAuthSupplier = new NoOpRegistryAuthSupplier(); + } else { + this.registryAuthSupplier = builder.registryAuthSupplier; + } this.client = ClientBuilder.newBuilder() .withConfig(config) @@ -1165,17 +1172,16 @@ public InputStream saveMultiple(final String... images) throws DockerException, IOException, InterruptedException { final WebTarget resource = resource().path("images").path("get"); - if (images != null) { - for (final String image : images) { - resource.queryParam("names", urlEncode(image)); - } + for (final String image : images) { + resource.queryParam("names", urlEncode(image)); } return request( GET, InputStream.class, resource, - resource.request(APPLICATION_JSON_TYPE).header("X-Registry-Auth", authHeader(registryAuth)) + resource.request(APPLICATION_JSON_TYPE).header("X-Registry-Auth", authHeader( + registryAuthSupplier.authFor(images[0]))) ); } @@ -1187,7 +1193,7 @@ public void pull(final String image) throws DockerException, InterruptedExceptio @Override public void pull(final String image, final ProgressHandler handler) throws DockerException, InterruptedException { - pull(image, registryAuth, handler); + pull(image, registryAuthSupplier.authFor(image), handler); } @Override @@ -1241,7 +1247,7 @@ public void push(final String image, final RegistryAuth registryAuth) @Override public void push(final String image, final ProgressHandler handler) throws DockerException, InterruptedException { - push(image, handler, registryAuth); + push(image, handler, registryAuthSupplier.authFor(image)); } @Override @@ -1360,18 +1366,7 @@ public String build(final Path directory, final String name, final String docker } // Convert auth to X-Registry-Config format - final RegistryConfigs registryConfigs; - if (registryAuth == null) { - registryConfigs = RegistryConfigs.empty(); - } else { - registryConfigs = RegistryConfigs.create(singletonMap( - registryAuth.serverAddress(), - RegistryConfigs.RegistryConfig.create( - registryAuth.serverAddress(), - registryAuth.username(), - registryAuth.password(), - registryAuth.email()))); - } + final RegistryConfigs registryConfigs = registryAuthSupplier.authForBuild(); try (final CompressedDirectory compressedDirectory = CompressedDirectory.create(directory); final InputStream fileStream = Files.newInputStream(compressedDirectory.file()); @@ -1784,8 +1779,7 @@ public void unlock(final UnlockKey unlockKey) throws DockerException, Interrupte @Override public ServiceCreateResponse createService(ServiceSpec spec) throws DockerException, InterruptedException { - - return createService(spec, registryAuth); + return createService(spec, registryAuthSupplier.authForSwarm()); } @Override @@ -2514,6 +2508,10 @@ public static Builder fromEnv() throws DockerCertificateException { public static class Builder { + public static final String ERROR_MESSAGE = + "LOGIC ERROR: DefaultDockerClient does not support being built " + + "with both `registryAuth` and `registryAuthSupplier`. " + + "Please build with at most one of these options."; private URI uri; private String apiVersion; private long connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT_MILLIS; @@ -2522,6 +2520,7 @@ public static class Builder { private DockerCertificatesStore dockerCertificatesStore; private boolean dockerAuth; private RegistryAuth registryAuth; + private RegistryAuthSupplier registryAuthSupplier; private Map headers = new HashMap<>(); public URI uri() { @@ -2632,7 +2631,9 @@ public boolean dockerAuth() { * * @param dockerAuth tells if Docker auth info should be used * @return Builder + * @deprecated in favor of {@link #registryAuthSupplier(RegistryAuthSupplier)} */ + @Deprecated public Builder dockerAuth(final boolean dockerAuth) { this.dockerAuth = dockerAuth; return this; @@ -2647,16 +2648,40 @@ public RegistryAuth registryAuth() { * * @param registryAuth RegistryAuth object * @return Builder + * + * @deprecated in favor of {@link #registryAuthSupplier(RegistryAuthSupplier)} */ + @Deprecated public Builder registryAuth(final RegistryAuth registryAuth) { + if (this.registryAuthSupplier != null) { + throw new IllegalStateException(ERROR_MESSAGE); + } this.registryAuth = registryAuth; + + // stuff the static RegistryAuth into a RegistryConfigs instance to maintain what + // DefaultDockerClient used to do with the RegistryAuth before we introduced the + // RegistryAuthSupplier + final RegistryConfigs configs = RegistryConfigs.create(singletonMap( + MoreObjects.firstNonNull(registryAuth.serverAddress(), ""), + registryAuth + )); + + this.registryAuthSupplier = new NoOpRegistryAuthSupplier(registryAuth, configs); + return this; + } + + public Builder registryAuthSupplier(final RegistryAuthSupplier registryAuthSupplier) { + if (this.registryAuthSupplier != null) { + throw new IllegalStateException(ERROR_MESSAGE); + } + this.registryAuthSupplier = registryAuthSupplier; return this; } public DefaultDockerClient build() { - if (dockerAuth) { + if (dockerAuth && registryAuthSupplier == null && registryAuth == null) { try { - this.registryAuth = RegistryAuth.fromDockerConfig().build(); + registryAuth(RegistryAuth.fromDockerConfig().build()); } catch (IOException e) { log.warn("Unable to use Docker auth info", e); } diff --git a/src/main/java/com/spotify/docker/client/DockerConfigReader.java b/src/main/java/com/spotify/docker/client/DockerConfigReader.java new file mode 100644 index 000000000..b18b98a95 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/DockerConfigReader.java @@ -0,0 +1,119 @@ +/*- + * -\-\- + * docker-client + * -- + * 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.docker.client; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Strings.isNullOrEmpty; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Preconditions; +import com.spotify.docker.client.messages.RegistryAuth; +import com.spotify.docker.client.messages.RegistryConfigs; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DockerConfigReader { + private static final Logger LOG = LoggerFactory.getLogger(DockerConfigReader.class); + + private static final ObjectMapper MAPPER = ObjectMapperProvider.objectMapper(); + + /** Returns all RegistryConfig instances from the configuration file. */ + public RegistryConfigs fromConfig(Path configPath) throws IOException { + return parseDockerConfig(configPath); + } + + public RegistryAuth fromConfig(Path configPath, String serverAddress) throws IOException { + return parseDockerConfig(configPath, serverAddress); + } + + /** + * @deprecated do not use - only exists for backwards compatibility. Use {@link #fromConfig(Path)} + * instead. + */ + @Deprecated + public RegistryAuth fromFirstConfig(Path configPath) throws IOException { + return parseDockerConfig(configPath, null); + } + + private RegistryAuth parseDockerConfig(final Path configPath, String serverAddress) + throws IOException { + checkNotNull(configPath); + + final Map configs = parseDockerConfig(configPath).configs(); + if (serverAddress != null && configs.containsKey(serverAddress) ) { + return configs.get(serverAddress); + } + + if (isNullOrEmpty(serverAddress)) { + if (configs.isEmpty()) { + return RegistryAuth.builder().build(); + } + LOG.warn("Returning first entry from docker config file - use fromConfig(Path) instead, " + + "this behavior is deprecated and will soon be removed"); + return configs.values().iterator().next(); + } + + throw new IllegalArgumentException( + "serverAddress=" + serverAddress + " does not appear in config file at " + configPath); + } + + private RegistryConfigs parseDockerConfig(final Path configPath) throws IOException { + checkNotNull(configPath); + return MAPPER.treeToValue(extractAuthJson(configPath), RegistryConfigs.class); + } + + public Path defaultConfigPath() { + final String home = System.getProperty("user.home"); + final Path dockerConfig = Paths.get(home, ".docker", "config.json"); + final Path dockerCfg = Paths.get(home, ".dockercfg"); + + if (Files.exists(dockerConfig)) { + LOG.debug("Using configfile: {}", dockerConfig); + return dockerConfig; + } else { + LOG.debug("Using configfile: {} ", dockerCfg); + return dockerCfg; + } + } + + private ObjectNode extractAuthJson(final Path configPath) throws IOException { + final JsonNode config = MAPPER.readTree(configPath.toFile()); + + Preconditions.checkState(config.isObject(), + "config file contents are not a JSON Object, instead it is a %s", config.getNodeType()); + + if (config.has("auths")) { + final JsonNode auths = config.get("auths"); + Preconditions.checkState(auths.isObject(), + "config file contents are not a JSON Object, instead it is a %s", auths.getNodeType()); + return (ObjectNode) auths; + } + + return (ObjectNode) config; + } +} diff --git a/src/main/java/com/spotify/docker/client/NoOpRegistryAuthSupplier.java b/src/main/java/com/spotify/docker/client/NoOpRegistryAuthSupplier.java new file mode 100644 index 000000000..eebd534e6 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/NoOpRegistryAuthSupplier.java @@ -0,0 +1,61 @@ +/*- + * -\-\- + * docker-client + * -- + * Copyright (C) 2016 - 2017 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.docker.client; + +import com.spotify.docker.client.exceptions.DockerException; +import com.spotify.docker.client.messages.RegistryAuth; +import com.spotify.docker.client.messages.RegistryAuthSupplier; +import com.spotify.docker.client.messages.RegistryConfigs; + +/** + * Wraps a RegistryAuth with the RegistryAuthSupplier interface. + */ +public class NoOpRegistryAuthSupplier implements RegistryAuthSupplier { + + private final RegistryAuth registryAuth; + private final RegistryConfigs configsForBuild; + + public NoOpRegistryAuthSupplier(final RegistryAuth registryAuth, + final RegistryConfigs configsForBuild) { + this.registryAuth = registryAuth; + this.configsForBuild = configsForBuild; + } + + public NoOpRegistryAuthSupplier() { + registryAuth = null; + configsForBuild = null; + } + + @Override + public RegistryAuth authFor(String imageName) throws DockerException { + return registryAuth; + } + + @Override + public RegistryAuth authForSwarm() { + return registryAuth; + } + + @Override + public RegistryConfigs authForBuild() { + return configsForBuild; + } +} diff --git a/src/main/java/com/spotify/docker/client/gcr/GCloudProcess.java b/src/main/java/com/spotify/docker/client/gcr/GCloudProcess.java new file mode 100644 index 000000000..0f4b554e1 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/gcr/GCloudProcess.java @@ -0,0 +1,31 @@ +/*- + * -\-\- + * docker-client + * -- + * 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.docker.client.gcr; + +import java.io.IOException; + +public class GCloudProcess { + + public Process runGcloudDocker() throws IOException { + return Runtime.getRuntime().exec("gcloud docker -a"); + } + +} diff --git a/src/main/java/com/spotify/docker/client/gcr/GoogleContainerRegistryAuthSupplier.java b/src/main/java/com/spotify/docker/client/gcr/GoogleContainerRegistryAuthSupplier.java new file mode 100644 index 000000000..ace09d900 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/gcr/GoogleContainerRegistryAuthSupplier.java @@ -0,0 +1,78 @@ +/*- + * -\-\- + * docker-client + * -- + * 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.docker.client.gcr; + +import com.spotify.docker.client.DockerConfigReader; +import com.spotify.docker.client.exceptions.DockerException; +import com.spotify.docker.client.messages.RegistryAuth; +import com.spotify.docker.client.messages.RegistryAuthSupplier; +import com.spotify.docker.client.messages.RegistryConfigs; +import java.io.IOException; +import java.nio.file.Path; +import org.apache.commons.lang.NotImplementedException; + +public class GoogleContainerRegistryAuthSupplier implements RegistryAuthSupplier { + + private final DockerConfigReader reader; + private final GoogleContainerRegistryCredRefresher refresher; + private final Path configPath; + + public GoogleContainerRegistryAuthSupplier() { + this(new DockerConfigReader(), + new GoogleContainerRegistryCredRefresher(new GCloudProcess()), + new DockerConfigReader().defaultConfigPath()); + } + + public GoogleContainerRegistryAuthSupplier( + final DockerConfigReader reader, + final GoogleContainerRegistryCredRefresher refresher, + final Path configPath) { + this.reader = reader; + this.refresher = refresher; + this.configPath = configPath; + } + + @Override + public RegistryAuth authFor(String imageName) throws DockerException { + try { + String registryName = "https://" + imageName.split("/")[0]; + refresher.refresh(); + return reader.fromConfig(configPath, registryName); + } catch (IOException ex) { + throw new DockerException(ex); + } + } + + @Override + public RegistryAuth authForSwarm() { + throw new NotImplementedException(); + } + + @Override + public RegistryConfigs authForBuild() throws DockerException { + try { + refresher.refresh(); + return reader.fromConfig(reader.defaultConfigPath()); + } catch (IOException ex) { + throw new DockerException(ex); + } + } +} diff --git a/src/main/java/com/spotify/docker/client/gcr/GoogleContainerRegistryCredRefresher.java b/src/main/java/com/spotify/docker/client/gcr/GoogleContainerRegistryCredRefresher.java new file mode 100644 index 000000000..6a96e8149 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/gcr/GoogleContainerRegistryCredRefresher.java @@ -0,0 +1,47 @@ +/*- + * -\-\- + * docker-client + * -- + * 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.docker.client.gcr; + +import com.spotify.docker.client.gcr.GCloudProcess; +import java.io.IOException; +import org.apache.commons.io.IOUtils; + +public class GoogleContainerRegistryCredRefresher { + + private final GCloudProcess gcloudProcess; + + public GoogleContainerRegistryCredRefresher(GCloudProcess gcloudProcess) { + this.gcloudProcess = gcloudProcess; + } + + public void refresh() throws IOException { + Process process = gcloudProcess.runGcloudDocker(); + try { + if (process.waitFor() != 0) { + throw new IOException(IOUtils.toString(process.getErrorStream(), "UTF-8")); + } + } catch (InterruptedException ex) { + throw new IOException(ex); + } + + } + +} diff --git a/src/main/java/com/spotify/docker/client/messages/RegistryAuth.java b/src/main/java/com/spotify/docker/client/messages/RegistryAuth.java index 3dc40f633..846cbca45 100644 --- a/src/main/java/com/spotify/docker/client/messages/RegistryAuth.java +++ b/src/main/java/com/spotify/docker/client/messages/RegistryAuth.java @@ -22,43 +22,23 @@ import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; -import static com.google.common.base.Preconditions.checkNotNull; -import static com.google.common.base.Strings.isNullOrEmpty; import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.auto.value.AutoValue; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; -import com.spotify.docker.client.ObjectMapperProvider; - +import com.spotify.docker.client.DockerConfigReader; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Iterator; - import javax.annotation.Nullable; - import org.glassfish.jersey.internal.util.Base64; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @AutoValue @JsonAutoDetect(fieldVisibility = ANY, getterVisibility = NONE, setterVisibility = NONE) public abstract class RegistryAuth { - private static final Logger LOG = LoggerFactory.getLogger(RegistryAuth.class); - - private static final String DEFAULT_REGISTRY = "https://index.docker.io/v1/"; - private static final String DUMMY_EMAIL = "1234@5678.com"; - - @SuppressWarnings("FieldCanBeLocal") - private static final ObjectMapper MAPPER = - new ObjectMapperProvider().getContext(RegistryAuth.class); - @Nullable @JsonProperty("Username") public abstract String username(); @@ -74,6 +54,7 @@ public abstract class RegistryAuth { @JsonProperty("Email") public abstract String email(); + @Nullable @JsonProperty("ServerAddress") public abstract String serverAddress(); @@ -81,8 +62,9 @@ public abstract class RegistryAuth { @JsonProperty("IdentityToken") public abstract String identityToken(); + @Override public final String toString() { - return MoreObjects.toStringHelper(this) + return MoreObjects.toStringHelper(RegistryAuth.class) .add("username", username()) // don't log the password or email .add("serverAddress", serverAddress()) @@ -99,10 +81,13 @@ public final String toString() { * * @return a {@link Builder} * @throws IOException when we can't parse the docker config file + * @deprecated in favor of registryAuthSupplier */ - @SuppressWarnings("unused") + @Deprecated + @SuppressWarnings({"deprecated", "unused"}) public static Builder fromDockerConfig() throws IOException { - return parseDockerConfig(defaultConfigPath(), null); + DockerConfigReader dockerCfgReader = new DockerConfigReader(); + return dockerCfgReader.fromFirstConfig(dockerCfgReader.defaultConfigPath()).toBuilder(); } /** @@ -116,7 +101,9 @@ public static Builder fromDockerConfig() throws IOException { */ @SuppressWarnings("unused") public static Builder fromDockerConfig(final String serverAddress) throws IOException { - return parseDockerConfig(defaultConfigPath(), serverAddress); + DockerConfigReader dockerCfgReader = new DockerConfigReader(); + return dockerCfgReader + .fromConfig(dockerCfgReader.defaultConfigPath(), serverAddress).toBuilder(); } /** @@ -129,7 +116,8 @@ public static Builder fromDockerConfig(final String serverAddress) throws IOExce */ @VisibleForTesting static Builder fromDockerConfig(final Path configPath) throws IOException { - return parseDockerConfig(configPath, null); + DockerConfigReader dockerCfgReader = new DockerConfigReader(); + return dockerCfgReader.fromConfig(configPath, null).toBuilder(); } /** @@ -144,91 +132,52 @@ static Builder fromDockerConfig(final Path configPath) throws IOException { @VisibleForTesting static Builder fromDockerConfig(final Path configPath, final String serverAddress) throws IOException { - return parseDockerConfig(configPath, serverAddress); + DockerConfigReader dockerConfigReader = new DockerConfigReader(); + return dockerConfigReader.fromConfig(configPath, serverAddress).toBuilder(); } - private static Path defaultConfigPath() { - final String home = System.getProperty("user.home"); - final Path dockerConfig = Paths.get(home, ".docker", "config.json"); - final Path dockerCfg = Paths.get(home, ".dockercfg"); - - if (Files.exists(dockerConfig)) { - LOG.debug("Using configfile: {}", dockerConfig); - return dockerConfig; - } else if (Files.exists(dockerCfg)) { - LOG.debug("Using configfile: {} ", dockerCfg); - return dockerCfg; + @JsonCreator + public static RegistryAuth create(@JsonProperty("username") String username, + @JsonProperty("password") String password, + @JsonProperty("email") final String email, + @JsonProperty("serverAddress") final String serverAddress, + @JsonProperty("identityToken") final String identityToken, + @JsonProperty("auth") final String auth) { + + final Builder builder; + if (auth != null) { + builder = forAuthToken(auth); } else { - throw new RuntimeException( - "Could not find a docker config. Please run 'docker login' to create one"); + builder = builder() + .username(username) + .password(password); } + return builder + .email(email) + .serverAddress(serverAddress) + .identityToken(identityToken) + .build(); } - private static RegistryAuth.Builder parseDockerConfig(final Path configPath, String serverAddress) - throws IOException { - checkNotNull(configPath); - final RegistryAuth.Builder authBuilder = RegistryAuth.builder(); - final JsonNode authJson = extractAuthJson(configPath); - - if (isNullOrEmpty(serverAddress)) { - final Iterator servers = authJson.fieldNames(); - if (servers.hasNext()) { - serverAddress = servers.next(); - } - } else { - if (!authJson.has(serverAddress)) { - LOG.error("Could not find auth config for {}. Returning empty builder", serverAddress); - return RegistryAuth.builder().serverAddress(serverAddress); - } - } + /** Construct a Builder based upon the "auth" field of the docker client config file. */ + public static Builder forAuthToken(String auth) { + final String[] authParams = Base64.decodeAsString(auth).split(":"); - final JsonNode serverAuth = authJson.get(serverAddress); - if (serverAuth != null && serverAuth.has("auth")) { - authBuilder.serverAddress(serverAddress); - final String authString = serverAuth.get("auth").asText(); - final String[] authParams = Base64.decodeAsString(authString).split(":"); - - if (authParams.length == 2) { - authBuilder.username(authParams[0].trim()); - authBuilder.password(authParams[1].trim()); - } else if (serverAuth.has("identityToken")) { - authBuilder.identityToken(serverAuth.get("identityToken").asText()); - return authBuilder; - } else { - LOG.warn("Failed to parse auth string for {}", serverAddress); - return authBuilder; - } - } else { - LOG.warn("Could not find auth field for {}", serverAddress); - return authBuilder; - } - - if (serverAuth.has("email")) { - authBuilder.email(serverAuth.get("email").asText()); - } - - return authBuilder; - } - - private static JsonNode extractAuthJson(final Path configPath) throws IOException { - final JsonNode config = MAPPER.readTree(configPath.toFile()); - - if (config.has("auths")) { - return config.get("auths"); + if (authParams.length != 2) { + return builder(); } - - return config; + return builder() + .username(authParams[0].trim()) + .password(authParams[1].trim()); } public static Builder builder() { - return new AutoValue_RegistryAuth.Builder() - // Default to the public Docker registry. - .serverAddress(DEFAULT_REGISTRY) - .email(DUMMY_EMAIL); + return new AutoValue_RegistryAuth.Builder(); } @AutoValue.Builder public abstract static class Builder { + public abstract Builder username(final String username); public abstract Builder password(final String password); diff --git a/src/main/java/com/spotify/docker/client/messages/RegistryAuthSupplier.java b/src/main/java/com/spotify/docker/client/messages/RegistryAuthSupplier.java new file mode 100644 index 000000000..b0bae4da3 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/messages/RegistryAuthSupplier.java @@ -0,0 +1,41 @@ +/*- + * -\-\- + * docker-client + * -- + * 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.docker.client.messages; + +import com.spotify.docker.client.exceptions.DockerException; + +public interface RegistryAuthSupplier { + + /** + * Returns a RegistryAuth object that works with a given registry's API [e.g. GCR]. + */ + RegistryAuth authFor(String imageName) throws DockerException; + + /** + * Returns a RegistryAuth object that is valid for a Docker Swarm context [i.e. not tied + * to specific image]. It's unnecessary if it's not planned to use this AuthSupplier to pull + * images for Swarm. + */ + RegistryAuth authForSwarm() throws DockerException; + + /** Authentication info to pass in the X-Registry-Config header when building an image. */ + RegistryConfigs authForBuild() throws DockerException; +} diff --git a/src/main/java/com/spotify/docker/client/messages/RegistryConfigs.java b/src/main/java/com/spotify/docker/client/messages/RegistryConfigs.java index 0479404db..1958112dd 100644 --- a/src/main/java/com/spotify/docker/client/messages/RegistryConfigs.java +++ b/src/main/java/com/spotify/docker/client/messages/RegistryConfigs.java @@ -25,14 +25,11 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; import com.google.auto.value.AutoValue; -import com.google.common.base.MoreObjects; - import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; import java.util.Collections; import java.util.Map; -import javax.annotation.Nullable; /** * A formatted string passed in X-Registry-Config request header. @@ -60,73 +57,36 @@ @JsonAutoDetect(fieldVisibility = ANY, getterVisibility = NONE, setterVisibility = NONE) public abstract class RegistryConfigs { - private static final RegistryConfigs EMPTY = - RegistryConfigs.create(Collections.emptyMap()); - public static RegistryConfigs empty() { - return EMPTY; + return RegistryConfigs.create(Collections.emptyMap()); } - public abstract ImmutableMap configs(); - - @AutoValue - public abstract static class RegistryConfig { - - // The address of the repository - @JsonProperty("serveraddress") - public abstract String serverAddress(); - - @Nullable - @JsonProperty("username") - public abstract String username(); - - @Nullable - @JsonProperty("password") - public abstract String password(); - - @Nullable - @JsonProperty("email") - public abstract String email(); - - // Not used but must be supplied - @JsonProperty("auth") - public abstract String auth(); + public abstract ImmutableMap configs(); - public static RegistryConfig create( - final String serveraddress, - final String username, - final String password, - final String email) { - return create(serveraddress, username, password, email, ""); - } - - @JsonCreator - static RegistryConfig create( - @JsonProperty("serveraddress") final String serveraddress, - @JsonProperty("username") final String username, - @JsonProperty("password") final String password, - @JsonProperty("email") final String email, - @JsonProperty("auth") final String auth) { - return new AutoValue_RegistryConfigs_RegistryConfig(serveraddress, username, password, email, - auth); - } - - // Override @AutoValue to not leak password - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("serverAddress", serverAddress()) - .add("username", username()) - .add("email", email()) - .add("auth", auth()) - .toString(); + @JsonCreator + public static RegistryConfigs create(final Map configs) { + if (configs == null) { + return new AutoValue_RegistryConfigs(ImmutableMap.of()); } - } - @JsonCreator - public static RegistryConfigs create(final Map configs) { - final ImmutableMap configsT = - configs == null ? ImmutableMap.of() : ImmutableMap.copyOf(configs); - return new AutoValue_RegistryConfigs(configsT); + // need to add serverAddress to each RegistryAuth instance; it is not available when + // Jackson is deserializing the RegistryAuth field + final Map transformedMap = Maps.transformEntries(configs, + new Maps.EntryTransformer() { + @Override + public RegistryAuth transformEntry(final String key, final RegistryAuth value) { + if (value == null) { + return null; + } + if (value.serverAddress() == null) { + return value.toBuilder() + .serverAddress(key) + .build(); + } + return value; + } + }); + + return new AutoValue_RegistryConfigs(ImmutableMap.copyOf(transformedMap)); } } diff --git a/src/test/java/com/spotify/docker/client/DefaultDockerClientTest.java b/src/test/java/com/spotify/docker/client/DefaultDockerClientTest.java index 7baf14309..d5bd74c30 100644 --- a/src/test/java/com/spotify/docker/client/DefaultDockerClientTest.java +++ b/src/test/java/com/spotify/docker/client/DefaultDockerClientTest.java @@ -60,7 +60,6 @@ import static java.lang.Long.toHexString; import static java.lang.String.format; import static java.lang.System.getenv; -import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; import static org.apache.commons.lang.StringUtils.containsIgnoreCase; import static org.awaitility.Awaitility.await; @@ -214,7 +213,6 @@ import com.spotify.docker.client.messages.swarm.ServiceSpec; import com.spotify.docker.client.messages.swarm.Swarm; import com.spotify.docker.client.messages.swarm.SwarmInit; -import com.spotify.docker.client.messages.swarm.SwarmJoin; import com.spotify.docker.client.messages.swarm.SwarmSpec; import com.spotify.docker.client.messages.swarm.Task; import com.spotify.docker.client.messages.swarm.TaskDefaults; @@ -312,19 +310,22 @@ public class DefaultDockerClientTest { @Rule public final TestName testName = new TestName(); + private final RegistryAuth registryAuth = RegistryAuth.builder() + .username(AUTH_USERNAME) + .password(AUTH_PASSWORD) + .email("1234@example.com") + .build(); + private final String nameTag = toHexString(ThreadLocalRandom.current().nextLong()); private URI dockerEndpoint; private DefaultDockerClient sut; - private RegistryAuth registryAuth; - private String dockerApiVersion; @Before public void setup() throws Exception { - registryAuth = RegistryAuth.builder().username(AUTH_USERNAME).password(AUTH_PASSWORD).build(); final DefaultDockerClient.Builder builder = DefaultDockerClient.fromEnv(); // Make it easier to test no read timeout occurs by using a smaller value // Such test methods should end in 'NoTimeout' @@ -689,6 +690,7 @@ public void testAuth() throws Exception { public void testBadAuth() throws Exception { final RegistryAuth badRegistryAuth = RegistryAuth.builder() .username(AUTH_USERNAME) + .email("user@example.com") // docker < 1.11 requires email to be set in RegistryAuth .password("foobar") .build(); final int statusCode = sut.auth(badRegistryAuth); diff --git a/src/test/java/com/spotify/docker/client/DefaultDockerClientUnitTest.java b/src/test/java/com/spotify/docker/client/DefaultDockerClientUnitTest.java index 1f24f6c82..e010d3088 100644 --- a/src/test/java/com/spotify/docker/client/DefaultDockerClientUnitTest.java +++ b/src/test/java/com/spotify/docker/client/DefaultDockerClientUnitTest.java @@ -24,16 +24,31 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; +import com.google.common.io.BaseEncoding; +import com.google.common.io.Resources; +import com.spotify.docker.client.exceptions.DockerCertificateException; +import com.spotify.docker.client.gcr.GoogleContainerRegistryAuthSupplier; import com.spotify.docker.client.messages.ContainerConfig; import com.spotify.docker.client.messages.HostConfig; +import com.spotify.docker.client.messages.RegistryAuth; +import com.spotify.docker.client.messages.RegistryAuthSupplier; +import com.spotify.docker.client.messages.RegistryConfigs; import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashSet; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -43,7 +58,9 @@ import okio.Buffer; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; /** * Tests DefaultDockerClient against a {@link okhttp3.mockwebserver.MockWebServer} instance, so @@ -69,8 +86,12 @@ public class DefaultDockerClientUnitTest { private final MockWebServer server = new MockWebServer(); + private DefaultDockerClient.Builder builder; + @Rule + public ExpectedException thrown = ExpectedException.none(); + @Before public void setup() throws Exception { server.start(); @@ -142,6 +163,18 @@ private static JsonNode toJson(Buffer buffer) throws IOException { return ObjectMapperProvider.objectMapper().readTree(buffer.inputStream()); } + private static JsonNode toJson(byte[] bytes) throws IOException { + return ObjectMapperProvider.objectMapper().readTree(bytes); + } + + private static JsonNode toJson(Object object) throws IOException { + return ObjectMapperProvider.objectMapper().valueToTree(object); + } + + private static ObjectNode createObjectNode() { + return ObjectMapperProvider.objectMapper().createObjectNode(); + } + @Test @SuppressWarnings("unchecked") public void testCapAddAndDrop() throws Exception { @@ -188,4 +221,83 @@ private static Set childrenTextNodes(ArrayNode arrayNode) { return texts; } + @Test + @SuppressWarnings("deprecated") + public void buildThrowsIfRegistryAuthandRegistryAuthSupplierAreBothSpecified() + throws DockerCertificateException { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("LOGIC ERROR"); + + DefaultDockerClient.builder() + .registryAuth(RegistryAuth.builder().identityToken("hello").build()) + .registryAuthSupplier(new GoogleContainerRegistryAuthSupplier()) + .build(); + } + + @Test + public void testBuildPassesMultipleRegistryConfigs() throws Exception { + final RegistryConfigs registryConfigs = RegistryConfigs.create(ImmutableMap.of( + "server1", RegistryAuth.builder() + .serverAddress("server1") + .username("u1") + .password("p1") + .email("e1") + .build(), + + "server2", RegistryAuth.builder() + .serverAddress("server2") + .username("u2") + .password("p2") + .email("e2") + .build() + )); + + final RegistryAuthSupplier authSupplier = mock(RegistryAuthSupplier.class); + when(authSupplier.authForBuild()).thenReturn(registryConfigs); + + final DefaultDockerClient client = builder.registryAuthSupplier(authSupplier) + .build(); + + // build() calls /version to check what format of header to send + server.enqueue(new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody( + createObjectNode() + .put("ApiVersion", "1.20") + .put("Arch", "foobar") + .put("GitCommit", "foobar") + .put("GoVersion", "foobar") + .put("KernelVersion", "foobar") + .put("Os", "foobar") + .put("Version", "1.20") + .toString() + ) + ); + + // TODO (mbrown): what to return for build response? + server.enqueue(new MockResponse() + .setResponseCode(200) + ); + + final Path path = Paths.get(Resources.getResource("dockerDirectory").toURI()); + + client.build(path); + + final RecordedRequest versionRequest = takeRequestImmediately(); + assertThat(versionRequest.getMethod(), is("GET")); + assertThat(versionRequest.getPath(), is("/version")); + + final RecordedRequest buildRequest = takeRequestImmediately(); + assertThat(buildRequest.getMethod(), is("POST")); + assertThat(buildRequest.getPath(), is("/build")); + + final String registryConfigHeader = buildRequest.getHeader("X-Registry-Config"); + assertThat(registryConfigHeader, is(not(nullValue()))); + + // check that the JSON in the header is equivalent to what we mocked out above from + // the registryAuthSupplier + final JsonNode headerJsonNode = toJson(BaseEncoding.base64().decode(registryConfigHeader)); + assertThat(headerJsonNode, is(toJson(registryConfigs.configs()))); + } } diff --git a/src/test/java/com/spotify/docker/client/DockerConfigReaderTest.java b/src/test/java/com/spotify/docker/client/DockerConfigReaderTest.java new file mode 100644 index 000000000..1fd5da2e7 --- /dev/null +++ b/src/test/java/com/spotify/docker/client/DockerConfigReaderTest.java @@ -0,0 +1,190 @@ +/*- + * -\-\- + * docker-client + * -- + * Copyright (C) 2016 - 2017 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. + * -/-/- + */ + +/* + * Copyright (c) 2017 + * + * 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.docker.client; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; + +import com.google.common.io.Resources; +import com.spotify.docker.client.messages.RegistryAuth; +import com.spotify.docker.client.messages.RegistryConfigs; +import java.io.FileNotFoundException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.apache.commons.lang.RandomStringUtils; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Matcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +@SuppressWarnings("deprecated") +public class DockerConfigReaderTest { + + private static final RegistryAuth DOCKER_AUTH_CONFIG = RegistryAuth.builder() + .serverAddress("https://index.docker.io/v1/") + .username("dockerman") + .password("sw4gy0lo") + .email("dockerman@hub.com") + .build(); + + private static final RegistryAuth MY_AUTH_CONFIG = RegistryAuth.builder() + .serverAddress("https://narnia.mydock.io/v1/") + .username("megaman") + .password("riffraff") + .email("megaman@mydock.com") + .build(); + + private static final RegistryAuth IDENTITY_TOKEN_AUTH_CONFIG = RegistryAuth.builder() + .email("dockerman@hub.com") + .serverAddress("docker.customdomain.com") + .identityToken("52ce5fd5-eb60-42bf-931f-5eeec128211a") + .build(); + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + private final DockerConfigReader reader = new DockerConfigReader(); + + @Test + public void testFromDockerConfig_FullConfig() throws Exception { + final RegistryAuth registryAuth = + reader.fromFirstConfig(getTestFilePath("dockerConfig/fullConfig.json")); + assertThat(registryAuth, equalTo(DOCKER_AUTH_CONFIG)); + } + + @Test + public void testFromDockerConfig_FullDockerCfg() throws Exception { + final RegistryAuth registryAuth = + reader.fromFirstConfig(getTestFilePath("dockerConfig/fullDockerCfg")); + assertThat(registryAuth, equalTo(DOCKER_AUTH_CONFIG)); + } + + @Test + public void testFromDockerConfig_IdentityToken() throws Exception { + final RegistryAuth authConfig = + reader.fromFirstConfig(getTestFilePath("dockerConfig/identityTokenConfig.json")); + assertThat(authConfig, equalTo(IDENTITY_TOKEN_AUTH_CONFIG)); + } + + @Test + public void testFromDockerConfig_IncompleteConfig() throws Exception { + final RegistryAuth registryAuth = + reader.fromFirstConfig(getTestFilePath("dockerConfig/incompleteConfig.json")); + + final RegistryAuth expected = RegistryAuth.builder() + .email("dockerman@hub.com") + .serverAddress("https://different.docker.io/v1/") + .build(); + + assertThat(registryAuth, is(expected)); + } + + @Test + public void testFromDockerConfig_WrongConfigs() throws Exception { + final RegistryAuth registryAuth1 = + reader.fromFirstConfig(getTestFilePath("dockerConfig/wrongConfig1.json")); + assertThat(registryAuth1, is(emptyRegistryAuth())); + + final RegistryAuth registryAuth2 = + reader.fromFirstConfig(getTestFilePath("dockerConfig/wrongConfig2.json")); + assertThat(registryAuth2, is(emptyRegistryAuth())); + } + + private static Matcher emptyRegistryAuth() { + return new CustomTypeSafeMatcher("an empty RegistryAuth") { + @Override + protected boolean matchesSafely(final RegistryAuth item) { + return item.email() == null + && item.identityToken() == null + && item.password() == null + && item.email() == null; + } + }; + } + + @Test + public void testFromDockerConfig_MissingConfigFile() throws Exception { + final Path randomPath = Paths.get(RandomStringUtils.randomAlphanumeric(16) + ".json"); + expectedException.expect(FileNotFoundException.class); + reader.fromFirstConfig(randomPath); + } + + @Test + public void testFromDockerConfig_MultiConfig() throws Exception { + final Path path = getTestFilePath("dockerConfig/multiConfig.json"); + + final RegistryAuth myDockParsed = reader.fromConfig(path, "https://narnia.mydock.io/v1/"); + assertThat(myDockParsed, equalTo(MY_AUTH_CONFIG)); + + final RegistryAuth dockerIoParsed = reader.fromConfig(path, "https://index.docker.io/v1/"); + assertThat(dockerIoParsed, equalTo(DOCKER_AUTH_CONFIG)); + } + + private static Path getTestFilePath(final String path) { + if (OsUtils.isLinux() || OsUtils.isOsX()) { + return getLinuxPath(path); + } else { + return getWindowsPath(path); + } + } + + private static Path getWindowsPath(final String path) { + final URL resource = DockerConfigReaderTest.class.getResource("/" + path); + return Paths.get(resource.getPath().substring(1)); + } + + private static Path getLinuxPath(final String path) { + return Paths.get(Resources.getResource(path).getPath()); + } + + @Test + public void testParseRegistryConfigs() throws Exception { + final Path path = getTestFilePath("dockerConfig/multiConfig.json"); + final RegistryConfigs configs = reader.fromConfig(path); + + assertThat(configs.configs(), allOf( + hasEntry("https://index.docker.io/v1/", DOCKER_AUTH_CONFIG), + hasEntry("https://narnia.mydock.io/v1/", MY_AUTH_CONFIG) + )); + } +} diff --git a/src/test/java/com/spotify/docker/client/NoOpRegistryAuthSupplierTest.java b/src/test/java/com/spotify/docker/client/NoOpRegistryAuthSupplierTest.java new file mode 100644 index 000000000..c3629be16 --- /dev/null +++ b/src/test/java/com/spotify/docker/client/NoOpRegistryAuthSupplierTest.java @@ -0,0 +1,47 @@ +/*- + * -\-\- + * docker-client + * -- + * Copyright (C) 2016 - 2017 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.docker.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; + +import com.spotify.docker.client.exceptions.DockerException; +import com.spotify.docker.client.messages.RegistryAuth; +import org.junit.Test; + +public class NoOpRegistryAuthSupplierTest { + + @Test + public void authForReturnsWrappedAuthRegistry() throws DockerException { + RegistryAuth registryAuth = mock(RegistryAuth.class); + NoOpRegistryAuthSupplier noOpRegistryAuthSupplier = new NoOpRegistryAuthSupplier(registryAuth, + null); + assertEquals(registryAuth, noOpRegistryAuthSupplier.authFor("doesn't matter")); + } + + @Test + public void authForReturnsNullForEmptyConstructor() throws DockerException { + NoOpRegistryAuthSupplier noOpRegistryAuthSupplier = new NoOpRegistryAuthSupplier(); + assertNull(noOpRegistryAuthSupplier.authFor("any")); + assertNull(noOpRegistryAuthSupplier.authForBuild()); + } +} diff --git a/src/test/java/com/spotify/docker/client/gcr/GoogleContainerRegistryAuthSupplierTest.java b/src/test/java/com/spotify/docker/client/gcr/GoogleContainerRegistryAuthSupplierTest.java new file mode 100644 index 000000000..8c32c768b --- /dev/null +++ b/src/test/java/com/spotify/docker/client/gcr/GoogleContainerRegistryAuthSupplierTest.java @@ -0,0 +1,80 @@ +/*- + * -\-\- + * docker-client + * -- + * Copyright (C) 2017 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.docker.client.gcr; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.spotify.docker.client.DockerConfigReader; +import com.spotify.docker.client.messages.RegistryAuth; +import java.nio.file.Path; +import org.junit.Test; +import org.mockito.InOrder; + +public class GoogleContainerRegistryAuthSupplierTest { + + @Test + public void authForRefreshesCredsBeforeReadingConfigFile() throws Exception { + DockerConfigReader dockerCfgReader = mock(DockerConfigReader.class); + GoogleContainerRegistryCredRefresher googleContainerRegistryCredRefresher = + mock(GoogleContainerRegistryCredRefresher.class); + Path path = mock(Path.class); + InOrder inOrder = inOrder(googleContainerRegistryCredRefresher, dockerCfgReader); + + GoogleContainerRegistryAuthSupplier googleContainerRegistryAuthSupplier = + new GoogleContainerRegistryAuthSupplier( + dockerCfgReader, googleContainerRegistryCredRefresher, path); + + googleContainerRegistryAuthSupplier.authFor("us.gcr.io/awesome-project/example-image"); + inOrder.verify(googleContainerRegistryCredRefresher).refresh(); + inOrder.verify(dockerCfgReader).fromConfig(any(Path.class), anyString()); + } + + @Test + public void authForReturnsRegistryAuthThatMatchesRegistryName() throws Exception { + DockerConfigReader dockerCfgReader = mock(DockerConfigReader.class); + GoogleContainerRegistryCredRefresher googleContainerRegistryCredRefresher = + mock(GoogleContainerRegistryCredRefresher.class); + Path path = mock(Path.class); + + RegistryAuth expected = + RegistryAuth.builder().email("no@no.com").identityToken("authorific").build(); + + when(dockerCfgReader.fromConfig(any(Path.class), eq("https://us.gcr.io"))).thenReturn(expected); + + GoogleContainerRegistryAuthSupplier googleContainerRegistryAuthSupplier = + new GoogleContainerRegistryAuthSupplier( + dockerCfgReader, googleContainerRegistryCredRefresher, path); + + RegistryAuth registryAuth = googleContainerRegistryAuthSupplier + .authFor("us.gcr.io/awesome-project/example-image"); + + assertEquals(expected.email(), registryAuth.email()); + assertEquals(expected.identityToken(), registryAuth.identityToken()); + + } + +} diff --git a/src/test/java/com/spotify/docker/client/gcr/GoogleContainerRegistryCredRefresherTest.java b/src/test/java/com/spotify/docker/client/gcr/GoogleContainerRegistryCredRefresherTest.java new file mode 100644 index 000000000..3d89b4409 --- /dev/null +++ b/src/test/java/com/spotify/docker/client/gcr/GoogleContainerRegistryCredRefresherTest.java @@ -0,0 +1,78 @@ +/*- + * -\-\- + * docker-client + * -- + * 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.docker.client.gcr; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +public class GoogleContainerRegistryCredRefresherTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + + @Test + public void refreshShellsOutToGCloudCli() throws IOException, InterruptedException { + Process process = mock(Process.class); + when(process.waitFor()).thenReturn(0); + + GCloudProcess gcloudProcess = mock(GCloudProcess.class); + when(gcloudProcess.runGcloudDocker()).thenReturn(process); + + GoogleContainerRegistryCredRefresher googleContainerRegistryCredRefresher = + new GoogleContainerRegistryCredRefresher(gcloudProcess); + + googleContainerRegistryCredRefresher.refresh(); + verify(gcloudProcess).runGcloudDocker(); + + } + + @Test + public void refreshThrowsIfSuccessCodeIsntReturnedFromCommand() + throws InterruptedException, IOException { + thrown.expect(IOException.class); + thrown.expectMessage("ERROR: (gcloud.docker)"); + + GCloudProcess gcloudProcess = mock(GCloudProcess.class); + + Process failedProc = mock(Process.class); + when(failedProc.waitFor()).thenReturn(1); + when(failedProc.getErrorStream()) + .thenReturn( + new ByteArrayInputStream("ERROR: (gcloud.docker)".getBytes(StandardCharsets.UTF_8))); + + when(gcloudProcess.runGcloudDocker()).thenReturn(failedProc); + + GoogleContainerRegistryCredRefresher googleContainerRegistryCredRefresher = + new GoogleContainerRegistryCredRefresher(gcloudProcess); + + googleContainerRegistryCredRefresher.refresh(); + } + +} diff --git a/src/test/java/com/spotify/docker/client/messages/RegistryAuthTest.java b/src/test/java/com/spotify/docker/client/messages/RegistryAuthTest.java deleted file mode 100644 index cf4c0aa52..000000000 --- a/src/test/java/com/spotify/docker/client/messages/RegistryAuthTest.java +++ /dev/null @@ -1,137 +0,0 @@ -/*- - * -\-\- - * docker-client - * -- - * 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.docker.client.messages; - -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.MatcherAssert.assertThat; - -import com.google.common.io.Resources; -import com.spotify.docker.client.OsUtils; - -import java.io.FileNotFoundException; -import java.net.URL; -import java.nio.file.Path; -import java.nio.file.Paths; - -import org.apache.commons.lang.RandomStringUtils; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; - -public class RegistryAuthTest { - - private static final RegistryAuth DOCKER_AUTH_CONFIG = RegistryAuth.builder() - .serverAddress("https://index.docker.io/v1/") - .username("dockerman") - .password("sw4gy0lo") - .email("dockerman@hub.com") - .build(); - - private static final RegistryAuth MY_AUTH_CONFIG = RegistryAuth.builder() - .serverAddress("https://narnia.mydock.io/v1/") - .username("megaman") - .password("riffraff") - .email("megaman@mydock.com") - .build(); - - private static final RegistryAuth IDENTITY_TOKEN_AUTH_CONFIG = RegistryAuth.builder() - .serverAddress("docker.customdomain.com") - .identityToken("52ce5fd5-eb60-42bf-931f-5eeec128211a") - .build(); - - private static final RegistryAuth EMPTY_AUTH_CONFIG = RegistryAuth.builder().build(); - - @Rule - public ExpectedException expectedException = ExpectedException.none(); - - @Test - public void testFromDockerConfig_FullConfig() throws Exception { - final RegistryAuth registryAuth = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/fullConfig.json")).build(); - assertThat(registryAuth, equalTo(DOCKER_AUTH_CONFIG)); - } - - @Test - public void testFromDockerConfig_FullDockerCfg() throws Exception { - final RegistryAuth registryAuth = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/fullDockerCfg")).build(); - assertThat(registryAuth, equalTo(DOCKER_AUTH_CONFIG)); - } - - @Test - public void testFromDockerConfig_IdentityToken() throws Exception { - final RegistryAuth authConfig = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/identityTokenConfig.json")).build(); - assertThat(authConfig, equalTo(IDENTITY_TOKEN_AUTH_CONFIG)); - } - - @Test - public void testFromDockerConfig_IncompleteConfig() throws Exception { - final RegistryAuth registryAuth = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/incompleteConfig.json")).build(); - assertThat(registryAuth, equalTo(EMPTY_AUTH_CONFIG)); - } - - @Test - public void testFromDockerConfig_WrongConfigs() throws Exception { - final RegistryAuth registryAuth1 = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/wrongConfig1.json")).build(); - assertThat(registryAuth1, equalTo(EMPTY_AUTH_CONFIG)); - - final RegistryAuth registryAuth2 = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/wrongConfig2.json")).build(); - assertThat(registryAuth2, equalTo(EMPTY_AUTH_CONFIG)); - } - - @Test - public void testFromDockerConfig_MissingConfigFile() throws Exception { - final Path randomPath = Paths.get(RandomStringUtils.randomAlphanumeric(16) + ".json"); - expectedException.expect(FileNotFoundException.class); - RegistryAuth.fromDockerConfig(randomPath).build(); - } - - @Test - public void testFromDockerConfig_MultiConfig() throws Exception { - final RegistryAuth myDockParsed = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/multiConfig.json"), "https://narnia.mydock.io/v1/").build(); - assertThat(myDockParsed, equalTo(MY_AUTH_CONFIG)); - final RegistryAuth dockerIoParsed = RegistryAuth.fromDockerConfig(getTestFilePath( - "dockerConfig/multiConfig.json"), "https://index.docker.io/v1/").build(); - assertThat(dockerIoParsed, equalTo(DOCKER_AUTH_CONFIG)); - } - - private static Path getTestFilePath(final String path) { - if (OsUtils.isLinux() || OsUtils.isOsX()) { - return getLinuxPath(path); - } else { - return getWindowsPath(path); - } - } - - private static Path getWindowsPath(final String path) { - final URL resource = RegistryAuthTest.class.getResource("/" + path); - return Paths.get(resource.getPath().substring(1)); - } - - private static Path getLinuxPath(final String path) { - return Paths.get(Resources.getResource(path).getPath()); - } -} diff --git a/src/test/java/com/spotify/docker/it/PushPullIT.java b/src/test/java/com/spotify/docker/it/PushPullIT.java index aa43a44e4..95fa244e2 100644 --- a/src/test/java/com/spotify/docker/it/PushPullIT.java +++ b/src/test/java/com/spotify/docker/it/PushPullIT.java @@ -21,6 +21,7 @@ package com.spotify.docker.it; import static com.google.common.base.Strings.isNullOrEmpty; +import static java.lang.System.getenv; import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.CoreMatchers.isA; @@ -34,12 +35,14 @@ import com.spotify.docker.client.exceptions.ContainerNotFoundException; import com.spotify.docker.client.exceptions.DockerException; import com.spotify.docker.client.exceptions.ImagePushFailedException; +import com.spotify.docker.client.gcr.GoogleContainerRegistryAuthSupplier; import com.spotify.docker.client.messages.ContainerConfig; import com.spotify.docker.client.messages.ContainerCreation; import com.spotify.docker.client.messages.ContainerInfo; import com.spotify.docker.client.messages.HostConfig; import com.spotify.docker.client.messages.PortBinding; import com.spotify.docker.client.messages.RegistryAuth; +import com.spotify.docker.client.messages.RegistryAuthSupplier; import java.nio.file.Paths; import java.util.Collections; import java.util.List; @@ -47,6 +50,7 @@ import java.util.concurrent.Callable; import javax.ws.rs.NotAuthorizedException; import org.junit.After; +import org.junit.Assume; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Rule; @@ -223,6 +227,27 @@ public void testPushHubPublicImageWithAuth() throws Exception { client.push(HUB_PUBLIC_IMAGE); } + + @Test + public void testPushGCRPrivateImageWithAuth() throws Exception { + String gcrPrivateImage = getenv("GCR_PRIVATE_IMAGE"); + Assume.assumeTrue("WARNING: Integration test for GCR has not run. " + + "Set env variable GCR_PRIVATE_IMAGE " + + "(e.g. GCR_PRIVATE_IMAGE=us.gcr.io/my-project/busybox) to run.", + gcrPrivateImage != null); + + final String dockerDirectory = Resources.getResource("dockerDirectory").getPath(); + RegistryAuthSupplier registryAuthSupplier = new GoogleContainerRegistryAuthSupplier(); + final DockerClient client = DefaultDockerClient + .fromEnv() + .registryAuthSupplier(registryAuthSupplier) + .build(); + + client.build(Paths.get(dockerDirectory), gcrPrivateImage); + client.push(gcrPrivateImage); + client.pull(gcrPrivateImage); + } + @Test public void testPushHubPrivateImageWithAuth() throws Exception { // Push an image to a private repo on Docker Hub and check it succeeds