diff --git a/CHANGELOG.md b/CHANGELOG.md index a58ef82c1..36b4a6f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ Not released yet - Make ContainerState.exitCode() return a Long instead of Integer ([1052][]) +- Refactor authentication with docker config.json file ([1051][]) + - Add support for `credsHelper` (Fixes [1037][]) + - Improve support for authenticating with multiple registries (Fixes [1042][]) +[1037]: https://github.com/spotify/docker-client/issues/1037 +[1042]: https://github.com/spotify/docker-client/issues/1042 +[1051]: https://github.com/spotify/docker-client/issues/1051 [1052]: https://github.com/spotify/docker-client/issues/1052 ## 8.11.6 diff --git a/src/main/java/com/spotify/docker/client/DefaultDockerClient.java b/src/main/java/com/spotify/docker/client/DefaultDockerClient.java index 8bfb24eaf..2cc901473 100644 --- a/src/main/java/com/spotify/docker/client/DefaultDockerClient.java +++ b/src/main/java/com/spotify/docker/client/DefaultDockerClient.java @@ -3166,14 +3166,6 @@ public RequestEntityProcessing getRequestEntityProcessing() { } public DefaultDockerClient build() { - if (dockerAuth && registryAuthSupplier == null && registryAuth == null) { - try { - registryAuth(RegistryAuth.fromDockerConfig().build()); - } catch (IOException e) { - log.warn("Unable to use Docker auth info", e); - } - } - // read the docker config file for auth info if nothing else was specified if (registryAuthSupplier == null) { registryAuthSupplier(new ConfigFileRegistryAuthSupplier()); diff --git a/src/main/java/com/spotify/docker/client/DockerConfig.java b/src/main/java/com/spotify/docker/client/DockerConfig.java new file mode 100644 index 000000000..a07f7e524 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/DockerConfig.java @@ -0,0 +1,101 @@ +/*- + * -\-\- + * docker-client + * -- + * Copyright (C) 2016 - 2018 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.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; + +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.collect.ImmutableMap; +import com.spotify.docker.client.messages.RegistryAuth; + +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Represents the contents of the docker config.json file. + */ +@AutoValue +@JsonAutoDetect(fieldVisibility = ANY, getterVisibility = NONE, setterVisibility = NONE) +public abstract class DockerConfig { + + @Nullable + @JsonProperty("credsHelpers") + public abstract ImmutableMap credsHelpers(); + + @Nullable + @JsonProperty("auths") + public abstract ImmutableMap auths(); + + @Nullable + @JsonProperty("HttpHeaders") + public abstract ImmutableMap httpHeaders(); + + @Nullable + @JsonProperty("credsStore") + public abstract String credsStore(); + + @Nullable + @JsonProperty("detachKeys") + public abstract String detachKeys(); + + @Nullable + @JsonProperty("stackOrchestrator") + public abstract String stackOrchestrator(); + + @Nullable + @JsonProperty("psFormat") + public abstract String psFormat(); + + @Nullable + @JsonProperty("imagesFormat") + public abstract String imagesFormat(); + + @JsonCreator + public static DockerConfig create( + @JsonProperty("credsHelpers") final Map credsHelpers, + @JsonProperty("auths") final Map auths, + @JsonProperty("HttpHeaders") final Map httpHeaders, + @JsonProperty("credsStore") final String credsStore, + @JsonProperty("detachKeys") final String detachKeys, + @JsonProperty("stackOrchestrator") final String stackOrchestrator, + @JsonProperty("psFormat") final String psFormat, + @JsonProperty("imagesFormat") final String imagesFormat) { + return new AutoValue_DockerConfig( + credsHelpers == null + ? ImmutableMap.of() + : ImmutableMap.copyOf(credsHelpers), + auths == null + ? ImmutableMap.of() + : ImmutableMap.copyOf(auths), + httpHeaders == null + ? ImmutableMap.of() + : ImmutableMap.copyOf(httpHeaders), + credsStore, + detachKeys, + stackOrchestrator, + psFormat, + imagesFormat); + } +} diff --git a/src/main/java/com/spotify/docker/client/DockerConfigReader.java b/src/main/java/com/spotify/docker/client/DockerConfigReader.java index 87b0fdff5..888e2c5d5 100644 --- a/src/main/java/com/spotify/docker/client/DockerConfigReader.java +++ b/src/main/java/com/spotify/docker/client/DockerConfigReader.java @@ -21,33 +21,21 @@ 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.core.JsonProcessingException; -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.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableCollection; +import com.spotify.docker.client.messages.DockerCredentialHelperAuth; import com.spotify.docker.client.messages.RegistryAuth; -import com.spotify.docker.client.messages.RegistryAuthV2; import com.spotify.docker.client.messages.RegistryConfigs; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.io.Writer; import java.net.URI; import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; -import java.util.HashMap; -import java.util.Iterator; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,12 +44,19 @@ public class DockerConfigReader { private static final Logger LOG = LoggerFactory.getLogger(DockerConfigReader.class); private static final ObjectMapper MAPPER = ObjectMapperProvider.objectMapper(); - private static final String AUTHS_ENTRY = "auths"; - private static final String CREDS_STORE = "credsStore"; - /** Returns all RegistryConfig instances from the configuration file. */ + + /** + * Parse the contents of the config file and generate all possible + * {@link RegistryAuth}s, which are bundled into a {@link RegistryConfigs} instance. + * @param configPath Path to config file. + * @return All registry configs that can be generated from the config file + * @throws IOException If the file cannot be read, or its JSON cannot be parsed + * @deprecated Use {@link #authForAllRegistries(Path)} instead. + */ + @Deprecated public RegistryConfigs fromConfig(final Path configPath) throws IOException { - return parseDockerConfig(configPath); + return authForAllRegistries(configPath); } /** @@ -69,50 +64,154 @@ public RegistryConfigs fromConfig(final Path configPath) throws IOException { * * @throws IllegalArgumentException if the config file does not contain registry auth info for the * registry + * @deprecated In favor of {@link #authForRegistry(Path, String)} */ + @Deprecated public RegistryAuth fromConfig(final Path configPath, final String serverAddress) throws IOException { - return parseDockerConfig(configPath, serverAddress); + return authForRegistry(configPath, serverAddress); } /** - * @deprecated do not use - only exists for backwards compatibility. Use {@link #fromConfig(Path)} - * instead. + * Return a single RegistryAuth from the default config file. + * If there is only one, it'll be that one. + * + * @return Some registry auth value. */ - @Deprecated - public RegistryAuth fromFirstConfig(Path configPath) throws IOException { - return parseDockerConfig(configPath, null); + public RegistryAuth anyRegistryAuth() throws IOException { + return anyRegistryAuth(defaultConfigPath()); } - private RegistryAuth parseDockerConfig(final Path configPath, final String serverAddress) - throws IOException { + /** + * Return a single RegistryAuth from the config file. + * If there are multiple RegistryAuth entries, which entry is returned from this method + * depends on hashing and should not be considered reliable. + * If there is only one entry, however, that will be the one returned. This is the + * primary use of this method, as a useful way to extract a RegistryAuth during testing. + * In that environment, the contents of the config file are known and controlled. In a + * production environment, the contents of the config file are less predictable. + * + * @param configPath Path to the docker config file. + * @return Some registry auth value. + */ + @VisibleForTesting + RegistryAuth anyRegistryAuth(final Path configPath) throws IOException { + final ImmutableCollection registryAuths = + authForAllRegistries(configPath).configs().values(); + return registryAuths.isEmpty() + ? RegistryAuth.builder().build() + : registryAuths.iterator().next(); + } + + /** + * Parse the contents of the config file and generate all possible + * {@link RegistryAuth}s, which are bundled into a {@link RegistryConfigs} instance. + * @param configPath Path to config file. + * @return All registry auths that can be generated from the config file + * @throws IOException If the file cannot be read, or its JSON cannot be parsed + */ + public RegistryConfigs authForAllRegistries(final Path configPath) throws IOException { checkNotNull(configPath); - final Map configs = parseDockerConfig(configPath).configs(); + final DockerConfig config = MAPPER.readValue(configPath.toFile(), DockerConfig.class); + if (config == null) { + return RegistryConfigs.empty(); + } + + final RegistryConfigs.Builder registryConfigsBuilder = RegistryConfigs.builder(); + + final Map credsHelpers = config.credsHelpers(); + final boolean hasCredsHelpers = credsHelpers != null && !credsHelpers.isEmpty(); + final Map auths = config.auths(); + final boolean hasAuths = auths != null && !auths.isEmpty(); + final String credsStore = config.credsStore(); + final boolean hasCredsStore = credsStore != null; + + // First use the credsHelpers, if there are any + if (hasCredsHelpers) { + for (final Map.Entry credsHelpersEntry : credsHelpers.entrySet()) { + final String registry = credsHelpersEntry.getKey(); + final String aCredsStore = credsHelpersEntry.getValue(); + registryConfigsBuilder.addConfig(registry, + authWithCredentialHelper(aCredsStore, registry)); + } + } + + // If there are any objects in "auths", they could take two forms. + // Older auths will map registry keys to objects with "auth" values, sometimes emails. + // Newer auths will map registry keys to empty objects. They expect you + // to use the credsStore to authenticate. + if (hasAuths) { + // We will use this empty RegistryAuth to check for empty auth values + final RegistryAuth empty = RegistryAuth.builder().build(); + + for (final Map.Entry authEntry : auths.entrySet()) { + final String registry = authEntry.getKey(); + final RegistryAuth registryAuth = authEntry.getValue(); + if (registryAuth == null || registryAuth.equals(empty)) { + // We have an empty object. Can we use credsStore? + if (hasCredsStore) { + registryConfigsBuilder.addConfig(registry, + authWithCredentialHelper(credsStore, registry)); + } // no else clause. If we can't fall back to credsStore, we can't auth. + } else { + // The auth object isn't empty. + // We need to add the registry to its properties, then + // add it to the RegistryConfigs + registryConfigsBuilder.addConfig(registry, + registryAuth.toBuilder().serverAddress(registry).build()); + } + } + } - if (isNullOrEmpty(serverAddress)) { - if (configs.isEmpty()) { - return RegistryAuth.builder().build(); + // If there are no credsHelpers or auths or credsStore, then the + // config may be in a very old format. There aren't any keys for different + // sections. The file is just a map of registries to auths. + // In other words, it looks like a RegistryConfigs. + // If we can map it to one, we'll return it. + if (!(hasAuths || hasCredsHelpers || hasCredsStore)) { + try { + return MAPPER.readValue(configPath.toFile(), RegistryConfigs.class); + } catch (IOException ignored) { + // Looks like that failed to parse. + // Eat the exception, fall through, and return empty object. } - 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(); } - if (configs.containsKey(serverAddress)) { - return configs.get(serverAddress); + return registryConfigsBuilder.build(); + } + + /** + * Generate {@link RegistryAuth} for the registry. + * + * @param configPath Path to the docker config file + * @param registry Docker registry for which to generate auth + * @return The generated authentication object + */ + public RegistryAuth authForRegistry(final Path configPath, final String registry) + throws IOException { + checkNotNull(configPath); + checkNotNull(registry); + + final DockerConfig config = MAPPER.readValue(configPath.toFile(), DockerConfig.class); + if (config == null) { + return RegistryAuth.builder().build(); } + final RegistryAuth registryAuth = authForRegistry(config, registry); + if (registryAuth != null) { + return registryAuth; + } // If the given server address didn't have a protocol try adding a protocol to the address. // This handles cases where older versions of Docker included the protocol when writing // auth tokens to config.json. try { - final URI serverAddressUri = new URI(serverAddress); + final URI serverAddressUri = new URI(registry); if (serverAddressUri.getScheme() == null) { for (String proto : Arrays.asList("https://", "http://")) { - final String addrWithProto = proto + serverAddress; - if (configs.containsKey(addrWithProto)) { - return configs.get(addrWithProto); + final RegistryAuth protoRegistryAuth = authForRegistry(config, proto + registry); + if (protoRegistryAuth != null) { + return protoRegistryAuth; } } } @@ -121,65 +220,25 @@ private RegistryAuth parseDockerConfig(final Path configPath, final String serve } throw new IllegalArgumentException( - "serverAddress=" + serverAddress + " does not appear in config file at " + configPath); + "registry \"" + registry + "\" does not appear in config file at " + configPath); } - private RegistryConfigs parseDockerConfig(final Path configPath) throws IOException { - checkNotNull(configPath); - - ObjectNode authJson = extractAuthJson(configPath); - - if (authJson.has(CREDS_STORE) && authJson.has(AUTHS_ENTRY)) { - String credsStore = authJson.get(CREDS_STORE).textValue(); - Map registryAuthMap = new HashMap<>(); - - ObjectNode auths = (ObjectNode)authJson.get(AUTHS_ENTRY); - Iterator serverIterator = auths.fieldNames(); - - while (serverIterator.hasNext()) { - String serverAddress = serverIterator.next(); - - Process process = Runtime.getRuntime().exec("docker-credential-" + credsStore + " get"); - - try (Writer outStreamWriter = new OutputStreamWriter( - process.getOutputStream(), StandardCharsets.UTF_8)) { - try (BufferedWriter writer = new BufferedWriter(outStreamWriter)) { - - writer.write(serverAddress + "\n"); - writer.flush(); - } - } + private RegistryAuth authForRegistry(final DockerConfig config, final String registry) + throws IOException { - try (InputStreamReader reader = new InputStreamReader( - process.getInputStream(), StandardCharsets.UTF_8)) { - try (BufferedReader input = new BufferedReader(reader)) { - String serverAuthDetails = input.readLine(); - // ErrCredentialsNotFound standardizes the not found error, so every helper returns - // the same message and docker can handle it properly. - // https://github.com/docker/docker-credential-helpers/blob/19b711cc92fbaa47533646fa8adb457d199c99e1/credentials/error.go#L4-L6 - if ("credentials not found in native keychain".equals(serverAuthDetails)) { - continue; - } - JsonNode serverAuthNode = MAPPER.readTree(serverAuthDetails); - RegistryAuthV2 serverAuth = - new RegistryAuthV2(serverAuthNode.get("Username").textValue(), - serverAuthNode.get("Secret").textValue(), - serverAuthNode.get("ServerURL").textValue()); - - registryAuthMap.put(serverAddress, serverAuth); - } - } - } - return RegistryConfigs.create(registryAuthMap); - } else if (authJson.has(AUTHS_ENTRY)) { - return MAPPER.treeToValue(authJson.get(AUTHS_ENTRY), RegistryConfigs.class); + // If the registry shows up in "auths", return it + final Map auths = config.auths(); + if (auths != null && auths.get(registry) != null) { + return auths.get(registry).toBuilder().serverAddress(registry).build(); } - try { - return MAPPER.treeToValue(authJson, RegistryConfigs.class); - } catch (JsonProcessingException e) { - return RegistryConfigs.empty(); + // Else, we use a credential helper. + final String credsStore = getCredentialStore(config, registry); + if (credsStore != null) { + return authWithCredentialHelper(credsStore, registry); } + + return null; } public Path defaultConfigPath() { @@ -196,20 +255,32 @@ public Path defaultConfigPath() { } } - private ObjectNode extractAuthJson(final Path configPath) throws IOException { - final File file = configPath.toFile(); - - final JsonNode config = MAPPER.readTree(file); - - Preconditions.checkState(config.isObject(), - "config file contents are not a JSON Object, instead it is a %s", config.getNodeType()); + /** + * Obtain auth using a credential helper. + * @param credsStore The name of the credential helper + * @param registry The registry for which we need to obtain auth + * @return A RegistryAuth object with a username, password, and server. + * @throws IOException This method attempts to execute + * "docker-credential-" + credsStore + " get". If you don't have the + * proper credential helper installed and on your path, this + * will fail. + */ + private RegistryAuth authWithCredentialHelper(final String credsStore, + final String registry) throws IOException { + final DockerCredentialHelperAuth dockerCredentialHelperAuth = + DockerCredentialHelper.get(credsStore, registry); + return dockerCredentialHelperAuth == null ? null : dockerCredentialHelperAuth.toRegistryAuth(); + } - if (config.has(AUTHS_ENTRY)) { - final JsonNode auths = config.get(AUTHS_ENTRY); - Preconditions.checkState(auths.isObject(), - "config file contents are not a JSON Object, instead it is a %s", auths.getNodeType()); - } + private String getCredentialStore(final DockerConfig config, final String registry) { + checkNotNull(config, "Docker config cannot be null"); + checkNotNull(registry, "registry cannot be null"); - return (ObjectNode) config; + // Check for the registry in the credsHelpers map first. + // If it isn't there, default to credsStore. + final Map credsHelpers = config.credsHelpers(); + return (credsHelpers != null && credsHelpers.containsKey(registry)) + ? credsHelpers.get(registry) + : config.credsStore(); } } diff --git a/src/main/java/com/spotify/docker/client/DockerCredentialHelper.java b/src/main/java/com/spotify/docker/client/DockerCredentialHelper.java new file mode 100644 index 000000000..1c42d53e2 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/DockerCredentialHelper.java @@ -0,0 +1,235 @@ +/*- + * -\-\- + * docker-client + * -- + * Copyright (C) 2016 - 2018 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.spotify.docker.client.messages.DockerCredentialHelperAuth; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class interacts with a docker credential helper. + * See https://github.com/docker/docker-credential-helpers. + * + *

The credential helpers are platform-specific ways of storing and retrieving + * registry auth information. Docker ships with OS-specific implementations, + * such as osxkeychain and wincred, as well as others. But they also allow + * third parties to implement their own credential helpers; for instance, + * Google (https://github.com/GoogleCloudPlatform/docker-credential-gcr) and + * Amazon (https://github.com/awslabs/amazon-ecr-credential-helper) have + * implementations for their cloud registries.

+ * + *

The main interface to this class is in four static methods, which perform the four + * operations of a credential helper: {@link #get(String, String)}, {@link #list(String)}, + * {@link #store(String, DockerCredentialHelperAuth)}, and {@link #erase(String, String)}. + * They all take the name of the credential helper as an argument; this value is usually read + * as the credsStore or a credsHelper from a docker config file (see {@link DockerConfig}).

+ * + *

The static methods all pass their operations down to a {@link CredentialHelperDelegate} + * instance. By default this instance executes a command on the system. However, the delegate + * is modifiable with {@link #setCredentialHelperDelegate(CredentialHelperDelegate)} and + * {@link #restoreSystemCredentialHelperDelegate()} to facilitate testing.

+ */ +public class DockerCredentialHelper { + private static final Logger log = LoggerFactory.getLogger(DockerConfigReader.class); + private static final ObjectMapper mapper = ObjectMapperProvider.objectMapper(); + + /** + * An interface to be mocked during testing. + */ + @VisibleForTesting + interface CredentialHelperDelegate { + + int store(String credsStore, DockerCredentialHelperAuth auth) + throws IOException, InterruptedException; + + int erase(String credsStore, String registry) throws IOException, InterruptedException; + + DockerCredentialHelperAuth get(String credsStore, String registry) throws IOException; + + Map list(String credsStore) throws IOException; + } + + /** + * The default credential helper delegate. + * Executes each credential helper operation on the system. + */ + private static final CredentialHelperDelegate SYSTEM_CREDENTIAL_HELPER_DELEGATE = + new CredentialHelperDelegate() { + + @Override + public int store(final String credsStore, final DockerCredentialHelperAuth auth) + throws IOException, InterruptedException { + final Process process = exec("store", credsStore); + + try (final Writer outStreamWriter = + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)) { + try (final BufferedWriter writer = new BufferedWriter(outStreamWriter)) { + writer.write(mapper.writeValueAsString(auth) + "\n"); + writer.flush(); + } + } + + return process.waitFor(); + } + + @Override + public int erase(final String credsStore, final String registry) + throws IOException, InterruptedException { + final Process process = exec("erase", credsStore); + + try (final Writer outStreamWriter = + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)) { + try (final BufferedWriter writer = new BufferedWriter(outStreamWriter)) { + writer.write(registry + "\n"); + writer.flush(); + } + } + + return process.waitFor(); + } + + @Override + public DockerCredentialHelperAuth get(final String credsStore, final String registry) + throws IOException { + final Process process = exec("get", credsStore); + + try (final Writer outStreamWriter = + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)) { + try (final BufferedWriter writer = new BufferedWriter(outStreamWriter)) { + writer.write(registry + "\n"); + writer.flush(); + } + } + + try (final InputStreamReader reader = + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) { + try (BufferedReader input = new BufferedReader(reader)) { + final String serverAuthDetails = input.readLine(); + // ErrCredentialsNotFound standardizes the not found error, so every helper returns + // the same message and docker can handle it properly. + // https://github.com/docker/docker-credential-helpers/blob/19b711cc92fbaa47533646fa8adb457d199c99e1/credentials/error.go#L4-L6 + if ("credentials not found in native keychain".equals(serverAuthDetails)) { + return null; + } + return mapper.readValue(serverAuthDetails, DockerCredentialHelperAuth.class); + } + } + } + + @Override + public Map list(final String credsStore) throws IOException { + final Process process = exec("list", credsStore); + + try (final InputStreamReader reader = + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) { + try (BufferedReader input = new BufferedReader(reader)) { + final String serverAuthDetails = input.readLine(); + if ("The specified item could not be found in the keychain.".equals(serverAuthDetails)) { + return null; + } + return mapper.readValue(serverAuthDetails, new TypeReference>() {}); + } + } + } + + private Process exec(final String subcommand, final String credsStore) throws IOException { + final String cmd = "docker-credential-" + credsStore + " " + subcommand; + log.debug("Executing \"{}\"", cmd); + return Runtime.getRuntime().exec(cmd); + } + }; + + private static CredentialHelperDelegate credentialHelperDelegate = + SYSTEM_CREDENTIAL_HELPER_DELEGATE; + + @VisibleForTesting + static void setCredentialHelperDelegate(final CredentialHelperDelegate delegate) { + credentialHelperDelegate = delegate; + } + + @VisibleForTesting + static void restoreSystemCredentialHelperDelegate() { + credentialHelperDelegate = SYSTEM_CREDENTIAL_HELPER_DELEGATE; + } + + /** + * Store an auth value in the credsStore. + * @param credsStore Name of the docker credential helper + * @param auth Auth object to store + * @return Exit code of the process + * @throws IOException When we cannot read from the credential helper + * @throws InterruptedException When writing to the credential helper + * is interrupted + */ + public static int store(final String credsStore, final DockerCredentialHelperAuth auth) + throws IOException, InterruptedException { + return credentialHelperDelegate.store(credsStore, auth); + } + + /** + * Erase an auth value from a credsStore matching a registry. + * @param credsStore Name of the docker credential helper + * @param registry The registry for which you want to erase the auth + * @return Exit code of the process + * @throws IOException When we cannot read from the credential helper + * @throws InterruptedException When writing to the credential helper + * is interrupted + */ + public static int erase(final String credsStore, final String registry) + throws IOException, InterruptedException { + return credentialHelperDelegate.erase(credsStore, registry); + } + + /** + * Get an auth value from a credsStore for a registry. + * @param credsStore Name of the docker credential helper + * @param registry The registry for which you want to auth + * @return A {@link DockerCredentialHelperAuth} auth object + * @throws IOException When we cannot read from the credential helper + */ + public static DockerCredentialHelperAuth get(final String credsStore, final String registry) + throws IOException { + return credentialHelperDelegate.get(credsStore, registry); + } + + /** + * Lists credentials stored in the credsStore + * @param credsStore Name of the docker credential helper + * @return Map of registries to auth identifiers. + * (For instance, usernames for which you have signed in.) + * @throws IOException When we cannot read from the credential helper + */ + public static Map list(final String credsStore) throws IOException { + return credentialHelperDelegate.list(credsStore); + } +} diff --git a/src/main/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplier.java b/src/main/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplier.java index 3ba2f553a..3d2bec9df 100644 --- a/src/main/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplier.java +++ b/src/main/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplier.java @@ -71,15 +71,15 @@ public RegistryAuth authFor(final String imageName) throws DockerException { try { // Some registries like Docker Hub and GCR include "https://" in the server address. // Others like quay.io don't. - final RegistryAuth registryAuth = reader.fromConfig(path, ref.getRegistryUrl()); + final RegistryAuth registryAuth = reader.authForRegistry(path, ref.getRegistryUrl()); if (registryAuth != null) { return registryAuth; } - return reader.fromConfig(path, ref.getRegistryName()); + return reader.authForRegistry(path, ref.getRegistryName()); } catch (IllegalArgumentException e) { log.debug("Failed first attempt to find auth for {}", ref.getRegistryUrl(), e); try { - return reader.fromConfig(path, ref.getRegistryName()); + return reader.authForRegistry(path, ref.getRegistryName()); } catch (IllegalArgumentException e2) { log.debug("Failed second attempt to find auth for {}", ref.getRegistryName(), e2); return null; @@ -103,7 +103,7 @@ public RegistryConfigs authForBuild() throws DockerException { } try { - return reader.fromConfig(path); + return reader.authForAllRegistries(path); } catch (IOException e) { throw new DockerException(e); } diff --git a/src/main/java/com/spotify/docker/client/messages/DockerCredentialHelperAuth.java b/src/main/java/com/spotify/docker/client/messages/DockerCredentialHelperAuth.java new file mode 100644 index 000000000..d00426121 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/messages/DockerCredentialHelperAuth.java @@ -0,0 +1,61 @@ +/*- + * -\-\- + * docker-client + * -- + * Copyright (C) 2016 - 2018 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.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.auto.value.AutoValue; + +/** + * Represents the auth response received from a docker credential helper + * on a "get" operation, or sent to a credential helper on a "store". + * + *

See {@link com.spotify.docker.client.DockerCredentialHelper}.

+ */ +@AutoValue +public abstract class DockerCredentialHelperAuth { + @JsonProperty("Username") + public abstract String username(); + + @JsonProperty("Secret") + public abstract String secret(); + + @JsonProperty("ServerURL") + public abstract String serverUrl(); + + @JsonCreator + public static DockerCredentialHelperAuth create( + @JsonProperty("Username") final String username, + @JsonProperty("Secret") final String secret, + @JsonProperty("ServerURL") final String serverUrl) { + return new AutoValue_DockerCredentialHelperAuth(username, secret, serverUrl); + } + + @JsonIgnore + public RegistryAuth toRegistryAuth() { + return RegistryAuth.builder() + .username(username()) + .password(secret()) + .serverAddress(serverUrl()) + .build(); + } +} 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 0844c6bee..9ebacbfd8 100644 --- a/src/main/java/com/spotify/docker/client/messages/RegistryAuth.java +++ b/src/main/java/com/spotify/docker/client/messages/RegistryAuth.java @@ -35,6 +35,17 @@ import javax.annotation.Nullable; import org.glassfish.jersey.internal.util.Base64; +/** + * Represents all the auth info for a particular registry. + * + *

These are sent to docker during authenticated registry operations + * in the X-Registry-Config header (see {@link RegistryConfigs}).

+ * + *

Typically these objects are built by requesting auth information from a + * {@link com.spotify.docker.client.DockerCredentialHelper}. However, in older less-secure + * docker versions, these can be written directly into the ~/.docker/config.json file, + * with the username and password joined with a ":" and base-64 encoded.

+ */ @AutoValue @JsonAutoDetect(fieldVisibility = ANY, getterVisibility = NONE, setterVisibility = NONE) public abstract class RegistryAuth { @@ -76,18 +87,18 @@ public final String toString() { /** * This function looks for and parses credentials for logging into Docker registries. We first - * look in ~/.docker/config.json and fallback to ~/.dockercfg. We use the first credential in the - * config file. These files are created from running `docker login`. + * look in ~/.docker/config.json and fallback to ~/.dockercfg. + * These files are created from running `docker login`. + * If the file contains multiple credentials, which entry is returned from this method + * depends on hashing and should not be considered reliable. * * @return a {@link Builder} * @throws IOException when we can't parse the docker config file - * @deprecated in favor of registryAuthSupplier + * @deprecated in favor of {@link com.spotify.docker.client.auth.ConfigFileRegistryAuthSupplier} */ @Deprecated - @SuppressWarnings({"deprecated", "unused"}) public static Builder fromDockerConfig() throws IOException { - DockerConfigReader dockerCfgReader = new DockerConfigReader(); - return dockerCfgReader.fromFirstConfig(dockerCfgReader.defaultConfigPath()).toBuilder(); + return new DockerConfigReader().anyRegistryAuth().toBuilder(); } /** @@ -103,37 +114,7 @@ public static Builder fromDockerConfig() throws IOException { public static Builder fromDockerConfig(final String serverAddress) throws IOException { DockerConfigReader dockerCfgReader = new DockerConfigReader(); return dockerCfgReader - .fromConfig(dockerCfgReader.defaultConfigPath(), serverAddress).toBuilder(); - } - - /** - * Returns the first credential from the specified path to the docker file. This method is - * package-local so we can test it. - * - * @param configPath The path to the config file - * @return a {@link Builder} - * @throws IOException when we can't parse the docker config file - */ - @VisibleForTesting - static Builder fromDockerConfig(final Path configPath) throws IOException { - DockerConfigReader dockerCfgReader = new DockerConfigReader(); - return dockerCfgReader.fromConfig(configPath, null).toBuilder(); - } - - /** - * Returns the specified credential from the specified path to the docker file. This method is - * package-local so we can test it. - * - * @param configPath The path to the config file - * @param serverAddress A string representing the server address - * @return a {@link Builder} - * @throws IOException If an IOException occurred - */ - @VisibleForTesting - static Builder fromDockerConfig(final Path configPath, final String serverAddress) - throws IOException { - DockerConfigReader dockerConfigReader = new DockerConfigReader(); - return dockerConfigReader.fromConfig(configPath, serverAddress).toBuilder(); + .authForRegistry(dockerCfgReader.defaultConfigPath(), serverAddress).toBuilder(); } @JsonCreator diff --git a/src/main/java/com/spotify/docker/client/messages/RegistryAuthV2.java b/src/main/java/com/spotify/docker/client/messages/RegistryAuthV2.java deleted file mode 100644 index ded0fffcf..000000000 --- a/src/main/java/com/spotify/docker/client/messages/RegistryAuthV2.java +++ /dev/null @@ -1,65 +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; - -public class RegistryAuthV2 extends RegistryAuth { - - private String username; - private String password; - private String serverAddress; - - - public RegistryAuthV2(String username, String password, String serverAddress) { - this.username = username; - this.password = password; - this.serverAddress = serverAddress; - } - - @Override - public String username() { - return username; - } - - @Override - public String password() { - return password; - } - - @Override - public String email() { - return null; - } - - @Override - public String serverAddress() { - return serverAddress; - } - - @Override - public String identityToken() { - return null; - } - - @Override - public Builder toBuilder() { - return null; - } -} 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 1958112dd..491c4d106 100644 --- a/src/main/java/com/spotify/docker/client/messages/RegistryConfigs.java +++ b/src/main/java/com/spotify/docker/client/messages/RegistryConfigs.java @@ -28,7 +28,6 @@ import com.google.auto.value.AutoValue; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; -import java.util.Collections; import java.util.Map; /** @@ -58,7 +57,7 @@ public abstract class RegistryConfigs { public static RegistryConfigs empty() { - return RegistryConfigs.create(Collections.emptyMap()); + return builder().build(); } public abstract ImmutableMap configs(); @@ -66,7 +65,7 @@ public static RegistryConfigs empty() { @JsonCreator public static RegistryConfigs create(final Map configs) { if (configs == null) { - return new AutoValue_RegistryConfigs(ImmutableMap.of()); + return empty(); } // need to add serverAddress to each RegistryAuth instance; it is not available when @@ -87,6 +86,24 @@ public RegistryAuth transformEntry(final String key, final RegistryAuth value) { } }); - return new AutoValue_RegistryConfigs(ImmutableMap.copyOf(transformedMap)); + return builder().configs(transformedMap).build(); + } + + public static Builder builder() { + return new AutoValue_RegistryConfigs.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder configs(Map configs); + + abstract ImmutableMap.Builder configsBuilder(); + + public Builder addConfig(final String server, final RegistryAuth registryAuth) { + configsBuilder().put(server, registryAuth); + return this; + } + + public abstract RegistryConfigs build(); } } diff --git a/src/test/java/com/spotify/docker/client/DockerConfigReaderTest.java b/src/test/java/com/spotify/docker/client/DockerConfigReaderTest.java index fd10a822b..c6d66b748 100644 --- a/src/test/java/com/spotify/docker/client/DockerConfigReaderTest.java +++ b/src/test/java/com/spotify/docker/client/DockerConfigReaderTest.java @@ -42,21 +42,25 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.hasEntry; import static org.hamcrest.Matchers.is; -import static org.junit.Assume.assumeTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.google.common.io.Resources; +import com.spotify.docker.client.DockerCredentialHelper.CredentialHelperDelegate; +import com.spotify.docker.client.messages.DockerCredentialHelperAuth; import com.spotify.docker.client.messages.RegistryAuth; import com.spotify.docker.client.messages.RegistryConfigs; -import java.io.BufferedWriter; + import java.io.FileNotFoundException; import java.io.IOException; -import java.io.OutputStreamWriter; 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.AfterClass; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -89,31 +93,44 @@ public class DockerConfigReaderTest { private final DockerConfigReader reader = new DockerConfigReader(); + private CredentialHelperDelegate credentialHelperDelegate; + + @Before + public void setup() { + credentialHelperDelegate = mock(CredentialHelperDelegate.class); + DockerCredentialHelper.setCredentialHelperDelegate(credentialHelperDelegate); + } + + @AfterClass + public static void afterClass() { + DockerCredentialHelper.restoreSystemCredentialHelperDelegate(); + } + @Test public void testFromDockerConfig_FullConfig() throws Exception { final RegistryAuth registryAuth = - reader.fromFirstConfig(getTestFilePath("dockerConfig/fullConfig.json")); + reader.anyRegistryAuth(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")); + reader.anyRegistryAuth(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")); + reader.anyRegistryAuth(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")); + reader.anyRegistryAuth(getTestFilePath("dockerConfig/incompleteConfig.json")); final RegistryAuth expected = RegistryAuth.builder() .email("dockerman@hub.com") @@ -126,11 +143,11 @@ public void testFromDockerConfig_IncompleteConfig() throws Exception { @Test public void testFromDockerConfig_WrongConfigs() throws Exception { final RegistryAuth registryAuth1 = - reader.fromFirstConfig(getTestFilePath("dockerConfig/wrongConfig1.json")); + reader.anyRegistryAuth(getTestFilePath("dockerConfig/wrongConfig1.json")); assertThat(registryAuth1, is(emptyRegistryAuth())); final RegistryAuth registryAuth2 = - reader.fromFirstConfig(getTestFilePath("dockerConfig/wrongConfig2.json")); + reader.anyRegistryAuth(getTestFilePath("dockerConfig/wrongConfig2.json")); assertThat(registryAuth2, is(emptyRegistryAuth())); } @@ -150,17 +167,17 @@ protected boolean matchesSafely(final RegistryAuth item) { public void testFromDockerConfig_MissingConfigFile() throws Exception { final Path randomPath = Paths.get(RandomStringUtils.randomAlphanumeric(16) + ".json"); expectedException.expect(FileNotFoundException.class); - reader.fromFirstConfig(randomPath); + reader.anyRegistryAuth(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/"); + final RegistryAuth myDockParsed = reader.authForRegistry(path, "https://narnia.mydock.io/v1/"); assertThat(myDockParsed, equalTo(MY_AUTH_CONFIG)); - final RegistryAuth dockerIoParsed = reader.fromConfig(path, "https://index.docker.io/v1/"); + final RegistryAuth dockerIoParsed = reader.authForRegistry(path, "https://index.docker.io/v1/"); assertThat(dockerIoParsed, equalTo(DOCKER_AUTH_CONFIG)); } @@ -169,105 +186,18 @@ public void testFromDockerConfig_AddressProtocol() throws IOException { final Path path = getTestFilePath("dockerConfig/protocolMissing.json"); // Server address matches exactly what's in the config file - final RegistryAuth noProto = reader.fromConfig(path, "docker.example.com"); + final RegistryAuth noProto = reader.authForRegistry(path, "docker.example.com"); assertThat(noProto.serverAddress(), equalTo("docker.example.com")); // Server address doesn't have a protocol but the entry in the config file does (https) - final RegistryAuth httpsProto = reader.fromConfig(path, "repo.example.com"); + final RegistryAuth httpsProto = reader.authForRegistry(path, "repo.example.com"); assertThat(httpsProto.serverAddress(), equalTo("https://repo.example.com")); // Server address doesn't have a protocol but the entry in the config file does (http) - final RegistryAuth httpProto = reader.fromConfig(path, "local.example.com"); + final RegistryAuth httpProto = reader.authForRegistry(path, "local.example.com"); assertThat(httpProto.serverAddress(), equalTo("http://local.example.com")); } - @Test - public void testFromDockerConfig_CredsStore() throws Exception { - assumeTrue("Need to have a credential store.", getAuthCredentialsExist()); - - String domain1 = "https://test.fakedomain.com"; - String domain2 = "https://test.fakedomain2.com"; - - String testAuth1 = "{\n" + "\t\"ServerURL\": \"" + domain1 + "\",\n" - + "\t\"Username\": \"david\",\n" + "\t\"Secret\": \"passw0rd1\"\n" + "}"; - String testAuth2 = "{\n" + "\t\"ServerURL\": \"" + domain2 + "\",\n" - + "\t\"Username\": \"carl\",\n" + "\t\"Secret\": \"myPassword\"\n" + "}"; - - storeAuthCredential(testAuth1); - storeAuthCredential(testAuth2); - - final Path path = getTestFilePath("dockerConfig/" + getCredsStoreFileName()); - final RegistryConfigs configs = reader.fromConfig(path); - - for (RegistryAuth authConfigs : configs.configs().values()) { - if (domain1.equals(authConfigs.serverAddress())) { - assertThat(authConfigs.username(), equalTo("david")); - assertThat(authConfigs.password(), equalTo("passw0rd1")); - } else if (domain2.equals(authConfigs.serverAddress())) { - assertThat(authConfigs.username(), equalTo("carl")); - assertThat(authConfigs.password(), equalTo("myPassword")); - } - } - eraseAuthCredential(domain1); - eraseAuthCredential(domain2); - } - - private void eraseAuthCredential(String domain1) throws IOException, InterruptedException { - // Erase the credentials from the store - Process process = Runtime.getRuntime().exec(getCredsStore() + " erase"); - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); - - writer.write(domain1 + "\n"); - writer.flush(); - writer.close(); - - process.waitFor(); - } - - private boolean getAuthCredentialsExist() throws InterruptedException { - boolean returnValue = false; - try { - Process process = Runtime.getRuntime().exec(getCredsStore() + " list"); - returnValue = process.waitFor() == 0; - } catch (IOException e) { - // Ignored. This is ok, it just means the cred store doesn't exist on this system. - } - return returnValue; - } - - private void storeAuthCredential(String testAuth1) throws IOException, InterruptedException { - Process process = Runtime.getRuntime().exec(getCredsStore() + " store"); - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); - - writer.write(testAuth1 + "\n"); - writer.flush(); - writer.close(); - - process.waitFor(); - } - - private static String getCredsStoreFileName() { - if (OsUtils.isOsX()) { - return "credsStoreConfigOSX.json"; - } else if (OsUtils.isLinux()) { - return "credsStoreConfigLinux.json"; - } else { - return "credsStoreConfigWin.json"; - } - } - - private static String getCredsStore() { - String credsStore; - if (OsUtils.isOsX()) { - credsStore = "docker-credential-osxkeychain"; - } else if (OsUtils.isLinux()) { - credsStore = "docker-credential-secretservice"; - } else { - credsStore = "docker-credential-wincred"; - } - return credsStore; - } - private static Path getTestFilePath(final String path) { if (OsUtils.isLinux() || OsUtils.isOsX()) { return getLinuxPath(path); @@ -288,7 +218,7 @@ private static Path getLinuxPath(final String path) { @Test public void testParseRegistryConfigs() throws Exception { final Path path = getTestFilePath("dockerConfig/multiConfig.json"); - final RegistryConfigs configs = reader.fromConfig(path); + final RegistryConfigs configs = reader.authForAllRegistries(path); assertThat(configs.configs(), allOf( hasEntry("https://index.docker.io/v1/", DOCKER_AUTH_CONFIG), @@ -299,7 +229,81 @@ public void testParseRegistryConfigs() throws Exception { @Test public void testParseNoAuths() throws Exception { final Path path = getTestFilePath("dockerConfig/noAuths.json"); - final RegistryConfigs configs = reader.fromConfig(path); + final RegistryConfigs configs = reader.authForAllRegistries(path); assertThat(configs, equalTo(RegistryConfigs.empty())); } + + @Test + public void testCredsHelpers() throws Exception { + final Path path = getTestFilePath("dockerConfig/credsHelpers.json"); + + final String registry1 = "https://foo.io"; + final String registry2 = "https://adventure.zone"; + final DockerCredentialHelperAuth testAuth1 = + DockerCredentialHelperAuth.create( + "cool user", + "cool password", + registry1 + ); + final DockerCredentialHelperAuth testAuth2 = + DockerCredentialHelperAuth.create( + "taako", + "lupe", + registry2 + ); + + when(credentialHelperDelegate.get("a-cred-helper", registry1)).thenReturn(testAuth1); + when(credentialHelperDelegate.get("magic-missile", registry2)).thenReturn(testAuth2); + + final RegistryConfigs expected = RegistryConfigs.builder() + .addConfig(registry1, testAuth1.toRegistryAuth()) + .addConfig(registry2, testAuth2.toRegistryAuth()) + .build(); + final RegistryConfigs configs = reader.authForAllRegistries(path); + + assertThat(configs, is(expected)); + } + + @Test + public void testCredsStoreAndCredsHelpersAndAuth() throws Exception { + final Path path = getTestFilePath("dockerConfig/credsStoreAndCredsHelpersAndAuth.json"); + + // This registry is in the file, in the "auths" sections + final String registry1 = DOCKER_AUTH_CONFIG.serverAddress(); + assertThat(reader.authForRegistry(path, registry1), is(DOCKER_AUTH_CONFIG)); + + // This registry is in the "credsHelpers" section. It will give us a + // credsStore value which will trigger our mock and give us testAuth2. + final String registry2 = "https://adventure.zone"; + final DockerCredentialHelperAuth testAuth2 = + DockerCredentialHelperAuth.create( + "taako", + "lupe", + registry2 + ); + when(credentialHelperDelegate.get("magic-missile", registry2)).thenReturn(testAuth2); + assertThat(reader.authForRegistry(path, registry2), is(testAuth2.toRegistryAuth())); + + // This registry is not in the "auths" or anywhere else. It should default + // to using the credsStore value, and our mock will return testAuth3. + final String registry3 = "https://rush.in"; + final DockerCredentialHelperAuth testAuth3 = + DockerCredentialHelperAuth.create( + "magnus", + "julia", + registry3 + ); + when(credentialHelperDelegate.get("starblaster", registry3)).thenReturn(testAuth3); + assertThat(reader.authForRegistry(path, registry3), is(testAuth3.toRegistryAuth())); + + // Finally, when we get auths for *all* registries in the file, we only expect + // auths for the two registries that are explicitly mentioned. + // Since registry1 is in the "auths" and registry2 is in the "credsHelpers", + // we will see auths for them. + final RegistryConfigs registryConfigs = RegistryConfigs.builder() + .addConfig(registry2, testAuth2.toRegistryAuth()) + .addConfig(registry1, DOCKER_AUTH_CONFIG) + .build(); + assertThat(reader.authForAllRegistries(path), is(registryConfigs)); + } } diff --git a/src/test/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplierTest.java b/src/test/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplierTest.java index dfd69957f..ed9e0b0d6 100644 --- a/src/test/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplierTest.java +++ b/src/test/java/com/spotify/docker/client/auth/ConfigFileRegistryAuthSupplierTest.java @@ -87,7 +87,7 @@ public void testAuthFor_Success() throws Exception { .username("abc123") .build(); - when(reader.fromConfig(configFile.toPath(), "foo.example.net")).thenReturn(auth); + when(reader.authForRegistry(configFile.toPath(), "foo.example.net")).thenReturn(auth); assertThat(supplier.authFor("foo.example.net/bar:1.2.3"), is(equalTo(auth))); } @@ -97,7 +97,7 @@ public void testAuthForSwarm_Unimplemented() throws Exception { assertThat(supplier.authForSwarm(), is(nullValue())); // force future implementors of this method to write a test - verify(reader, never()).fromConfig(any(Path.class)); + verify(reader, never()).authForAllRegistries(any(Path.class)); } @Test @@ -129,7 +129,7 @@ public void testAuthForBuild_Success() throws Exception { .build() )); - when(reader.fromConfig(configFile.toPath())).thenReturn(configs); + when(reader.authForAllRegistries(configFile.toPath())).thenReturn(configs); assertThat(supplier.authForBuild(), is(equalTo(configs))); } diff --git a/src/test/resources/dockerConfig/credsHelpers.json b/src/test/resources/dockerConfig/credsHelpers.json new file mode 100644 index 000000000..ea15e53ba --- /dev/null +++ b/src/test/resources/dockerConfig/credsHelpers.json @@ -0,0 +1,7 @@ +{ + "credsHelpers": { + "https://foo.io": "a-cred-helper", + "https://adventure.zone": "magic-missile" + }, + "credsStore": "fallback" +} \ No newline at end of file diff --git a/src/test/resources/dockerConfig/credsStoreAndCredsHelpersAndAuth.json b/src/test/resources/dockerConfig/credsStoreAndCredsHelpersAndAuth.json new file mode 100644 index 000000000..38b99a8a4 --- /dev/null +++ b/src/test/resources/dockerConfig/credsStoreAndCredsHelpersAndAuth.json @@ -0,0 +1,12 @@ +{ + "auths": { + "https://index.docker.io/v1/": { + "auth": "ZG9ja2VybWFuOnN3NGd5MGxvCg==", + "email": "dockerman@hub.com" + } + }, + "credsHelpers": { + "https://adventure.zone": "magic-missile" + }, + "credsStore": "starblaster" +} \ No newline at end of file diff --git a/src/test/resources/dockerConfig/credsStoreConfigLinux.json b/src/test/resources/dockerConfig/credsStoreConfigLinux.json deleted file mode 100644 index 7810baccd..000000000 --- a/src/test/resources/dockerConfig/credsStoreConfigLinux.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "auths" : { - "https://test.fakedomain.com": { - - }, - "https://test.fakedomain2.com": { - - } - }, - "credsStore" : "secretservice" -} \ No newline at end of file diff --git a/src/test/resources/dockerConfig/credsStoreConfigOSX.json b/src/test/resources/dockerConfig/credsStoreConfigOSX.json deleted file mode 100644 index 00e84b15c..000000000 --- a/src/test/resources/dockerConfig/credsStoreConfigOSX.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "auths" : { - "https://test.fakedomain.com": { - - }, - "https://test.fakedomain2.com": { - - } - }, - "credsStore" : "osxkeychain" -} \ No newline at end of file diff --git a/src/test/resources/dockerConfig/credsStoreConfigWin.json b/src/test/resources/dockerConfig/credsStoreConfigWin.json deleted file mode 100644 index ea1ecb4b5..000000000 --- a/src/test/resources/dockerConfig/credsStoreConfigWin.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "auths" : { - "https://test.fakedomain.com": { - - }, - "https://test.fakedomain2.com": { - - } - }, - "credsStore" : "wincred" -} \ No newline at end of file