diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java index c461280df680..332e61710601 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java @@ -27,6 +27,7 @@ import org.springframework.boot.buildpack.platform.docker.TotalProgressPushListener; import org.springframework.boot.buildpack.platform.docker.UpdateListener; import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication; import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost; import org.springframework.boot.buildpack.platform.docker.transport.DockerEngineException; import org.springframework.boot.buildpack.platform.docker.type.Binding; @@ -102,9 +103,8 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio Assert.notNull(request, "'request' must not be null"); this.log.start(request); validateBindings(request.getBindings()); - String domain = request.getBuilder().getDomain(); PullPolicy pullPolicy = request.getPullPolicy(); - ImageFetcher imageFetcher = new ImageFetcher(domain, getBuilderAuthHeader(), pullPolicy, + ImageFetcher imageFetcher = new ImageFetcher(getBuilderRegistryAuthentication(), pullPolicy, request.getImagePlatform()); Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder()); BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage); @@ -203,18 +203,20 @@ private void pushImages(ImageReference name, List tags) throws I private void pushImage(ImageReference reference) throws IOException { Consumer progressConsumer = this.log.pushingImage(reference); TotalProgressPushListener listener = new TotalProgressPushListener(progressConsumer); - this.docker.image().push(reference, listener, getPublishAuthHeader()); + this.docker.image().push(reference, listener, getPublishAuthHeader(reference)); this.log.pushedImage(reference); } - private String getBuilderAuthHeader() { - return (this.dockerConfiguration != null && this.dockerConfiguration.getBuilderRegistryAuthentication() != null) - ? this.dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader() : null; + private DockerRegistryAuthentication getBuilderRegistryAuthentication() { + if (this.dockerConfiguration != null) { + return this.dockerConfiguration.getBuilderRegistryAuthentication(); + } + return null; } - private String getPublishAuthHeader() { + private String getPublishAuthHeader(ImageReference imageReference) { return (this.dockerConfiguration != null && this.dockerConfiguration.getPublishRegistryAuthentication() != null) - ? this.dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader() : null; + ? this.dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(imageReference) : null; } /** @@ -222,17 +224,14 @@ private String getPublishAuthHeader() { */ private class ImageFetcher { - private final String domain; - - private final String authHeader; + private final DockerRegistryAuthentication authentication; private final PullPolicy pullPolicy; private ImagePlatform defaultPlatform; - ImageFetcher(String domain, String authHeader, PullPolicy pullPolicy, ImagePlatform platform) { - this.domain = domain; - this.authHeader = authHeader; + ImageFetcher(DockerRegistryAuthentication authentication, PullPolicy pullPolicy, ImagePlatform platform) { + this.authentication = authentication; this.pullPolicy = pullPolicy; this.defaultPlatform = platform; } @@ -240,27 +239,25 @@ private class ImageFetcher { Image fetchImage(ImageType type, ImageReference reference) throws IOException { Assert.notNull(type, "'type' must not be null"); Assert.notNull(reference, "'reference' must not be null"); - Assert.state(this.authHeader == null || reference.getDomain().equals(this.domain), - () -> String.format("%s '%s' must be pulled from the '%s' authenticated registry", - StringUtils.capitalize(type.getDescription()), reference, this.domain)); + String authHeader = getAuthHeader(reference); if (this.pullPolicy == PullPolicy.ALWAYS) { - return checkPlatformMismatch(pullImage(reference, type), reference); + return checkPlatformMismatch(pullImage(authHeader, reference, type), reference); } try { return checkPlatformMismatch(Builder.this.docker.image().inspect(reference), reference); } catch (DockerEngineException ex) { if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) { - return checkPlatformMismatch(pullImage(reference, type), reference); + return checkPlatformMismatch(pullImage(authHeader, reference, type), reference); } throw ex; } } - private Image pullImage(ImageReference reference, ImageType imageType) throws IOException { + private Image pullImage(String authHeader, ImageReference reference, ImageType imageType) throws IOException { TotalProgressPullListener listener = new TotalProgressPullListener( Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType)); - Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, this.authHeader); + Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, authHeader); Builder.this.log.pulledImage(image, imageType); if (this.defaultPlatform == null) { this.defaultPlatform = ImagePlatform.from(image); @@ -278,6 +275,10 @@ private Image checkPlatformMismatch(Image image, ImageReference imageReference) return image; } + private String getAuthHeader(ImageReference reference) { + return (this.authentication != null) ? this.authentication.getAuthHeader(reference) : null; + } + } private static final class PlatformMismatchException extends RuntimeException { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/Credentials.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/Credentials.java new file mode 100644 index 000000000000..299d6b9cf715 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/Credentials.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import java.lang.invoke.MethodHandles; + +import com.fasterxml.jackson.databind.JsonNode; + +import org.springframework.boot.buildpack.platform.json.MappedObject; + +/** + * A class that represents credentials for a server. + * + * @author Dmytro Nosan + */ +class Credentials extends MappedObject { + + /** + * If the secret being stored is an identity token, the username should be set to + * {@code }. + */ + private static final String TOKEN_USERNAME = ""; + + private final String serverUrl; + + private final String username; + + private final String secret; + + /** + * Create a new {@link Credentials} instance from the given JSON node. + * @param node the JSON node to read from + */ + Credentials(JsonNode node) { + super(node, MethodHandles.lookup()); + this.serverUrl = valueAt("/ServerURL", String.class); + this.username = valueAt("/Username", String.class); + this.secret = valueAt("/Secret", String.class); + } + + /** + * Checks if the secret being stored is an identity token. + * @return true if the secret is an identity token, false otherwise + */ + boolean isIdentityToken() { + return TOKEN_USERNAME.equals(this.username); + } + + /** + * Returns the server URL associated with this credential. + * @return the server URL + */ + String getServerUrl() { + return this.serverUrl; + } + + /** + * Returns the username associated with the credential. + * @return the username + */ + String getUsername() { + return this.username; + } + + /** + * Returns the secret associated with this credential. + * @return the secret + */ + String getSecret() { + return this.secret; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerCredentialHelper.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerCredentialHelper.java new file mode 100644 index 000000000000..902488e55a9a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerCredentialHelper.java @@ -0,0 +1,117 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import com.sun.jna.Platform; + +import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; + +/** + * Default implementation of the {@link DockerCredentialHelper} that retrieves Docker + * credentials using a specified credential helper. + * + * @author Dmytro Nosan + */ +class DefaultDockerCredentialHelper implements DockerCredentialHelper { + + private static final String USR_LOCAL_BIN = "/usr/local/bin/"; + + private static final String CREDENTIALS_NOT_FOUND = "credentials not found in native keychain"; + + private static final String CREDENTIALS_URL_MISSING = "no credentials server URL"; + + private static final String CREDENTIALS_USERNAME_MISSING = "no credentials username"; + + private final String name; + + /** + * Creates a new {@link DefaultDockerCredentialHelper} instance using the specified + * credential helper name. + * @param name the full name of the Docker credential helper, e.g., + * {@code docker-credential-osxkeychain}, {@code docker-credential-desktop}, etc. + */ + DefaultDockerCredentialHelper(String name) { + this.name = name; + } + + @Override + public Credentials get(String serverUrl) throws IOException { + ProcessBuilder processBuilder = new ProcessBuilder().redirectErrorStream(true); + if (Platform.isWindows()) { + processBuilder.command("cmd", "/c"); + } + processBuilder.command(this.name, "get"); + Process process = startProcess(processBuilder); + try (OutputStream os = process.getOutputStream()) { + os.write(serverUrl.getBytes(StandardCharsets.UTF_8)); + } + int exitCode; + try { + exitCode = process.waitFor(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } + if (exitCode != 0) { + try (InputStream is = process.getInputStream()) { + String message = new String(is.readAllBytes(), StandardCharsets.UTF_8); + if (isCredentialsNotFoundError(message)) { + return null; + } + throw new IOException("%s' exited with code %d: %s".formatted(process, exitCode, message)); + } + } + try (InputStream is = process.getInputStream()) { + return new Credentials(SharedObjectMapper.get().readTree(is)); + } + } + + private Process startProcess(ProcessBuilder processBuilder) throws IOException { + try { + return processBuilder.start(); + } + catch (IOException ex) { + if (Platform.isMac()) { + try { + List command = new ArrayList<>(processBuilder.command()); + command.set(0, USR_LOCAL_BIN + command.get(0)); + return processBuilder.command(command).start(); + } + catch (IOException ignore) { + // Ignore, rethrow the original exception + } + } + throw ex; + } + } + + private boolean isCredentialsNotFoundError(String message) { + return switch (message.trim()) { + case CREDENTIALS_NOT_FOUND, CREDENTIALS_URL_MISSING, CREDENTIALS_USERNAME_MISSING -> true; + default -> false; + }; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerRegistryAuthentication.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerRegistryAuthentication.java new file mode 100644 index 000000000000..d41fc43d0302 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerRegistryAuthentication.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.Auth; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerConfig; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; +import org.springframework.boot.buildpack.platform.system.Environment; +import org.springframework.util.StringUtils; +import org.springframework.util.function.SingletonSupplier; + +/** + * A default implementation of {@link DockerRegistryAuthentication} that provides + * authentication using a Docker configuration file, leveraging both credential helpers + * and static credentials. + * + * @author Dmytro Nosan + */ +class DefaultDockerRegistryAuthentication implements DockerRegistryAuthentication { + + private static final String DEFAULT_DOMAIN = "docker.io"; + + private static final String INDEX_URL = "https://index.docker.io/v1/"; + + private final Map cache = new ConcurrentHashMap<>(); + + private final Function dockerCredentialHelperFactory; + + private final Supplier dockerConfigSupplier; + + DefaultDockerRegistryAuthentication() { + this(Environment.SYSTEM, DockerCredentialHelper::ofSuffix); + } + + /** + * Creates a {@code DockerConfigFileDockerRegistryAuthentication} instance using the + * provided {@link Environment} and {@link DockerCredentialHelper} factory. + * @param environment the environment from which to retrieve environment variables + * @param dockerCredentialHelperFactory the factory to create a + * {@link DockerCredentialHelper} instance based on the provided credential helper + * name. The factory is invoked with the credential helper name. For example, + * {@code desktop}, {@code osxkeychain}, etc. + */ + DefaultDockerRegistryAuthentication(Environment environment, + Function dockerCredentialHelperFactory) { + this.dockerConfigSupplier = SingletonSupplier + .of(() -> DockerConfigurationMetadata.from(environment).getConfiguration()); + this.dockerCredentialHelperFactory = dockerCredentialHelperFactory; + } + + @Override + public String getAuthHeader(ImageReference imageReference) { + // TODO: Should the authentication header be cached? The Docker CLI does not cache + // it, whereas testcontainers does. Is caching safe in this context? + return this.cache.computeIfAbsent(getServerUrl(imageReference), (serverUrl) -> { + DockerConfig dockerConfig = this.dockerConfigSupplier.get(); + return getAuthentication(dockerConfig, serverUrl).getAuthHeader(imageReference); + }); + } + + private String getServerUrl(ImageReference imageReference) { + String domain = imageReference.getDomain(); + return DEFAULT_DOMAIN.equals(domain) ? INDEX_URL : domain; + } + + private DockerRegistryAuthentication getAuthentication(DockerConfig dockerConfig, String serverUrl) { + RegistryAuth auth = getAuth(dockerConfig, serverUrl); + Credentials credentials = getCredentials(dockerConfig, serverUrl); + if (credentials != null && credentials.isIdentityToken()) { + return new DockerRegistryTokenAuthentication(credentials.getSecret()); + } + if (credentials != null) { + return new DockerRegistryUserAuthentication(credentials.getUsername(), credentials.getSecret(), + (credentials.getServerUrl() != null) ? credentials.getServerUrl() : serverUrl, + (auth != null) ? auth.email() : null); + } + if (auth != null) { + return new DockerRegistryUserAuthentication(auth.username(), auth.password(), auth.serverUrl(), + auth.email()); + } + return new DockerRegistryUserAuthentication("", "", "", ""); + } + + private Credentials getCredentials(DockerConfig dockerConfig, String serverUrl) { + try { + String helper = dockerConfig.getCredHelpers().getOrDefault(serverUrl, dockerConfig.getCredsStore()); + return StringUtils.hasText(helper) ? this.dockerCredentialHelperFactory.apply(helper).get(serverUrl) : null; + } + catch (IOException ex) { + System.err.printf("Error retrieving credentials for '%s' due to: %s%n", serverUrl, ex.getMessage()); + } + return null; + } + + private RegistryAuth getAuth(DockerConfig dockerConfig, String serverUrl) { + return dockerConfig.getAuths() + .entrySet() + .stream() + .filter((entry) -> entry.getKey().equals(serverUrl) || entry.getKey().endsWith("://" + serverUrl)) + .map((entry) -> new RegistryAuth(entry.getKey(), entry.getValue())) + .findFirst() + .orElse(null); + } + + private record RegistryAuth(String serverUrl, String username, String password, String email) { + private RegistryAuth(String serverUrl, Auth auth) { + this(serverUrl, auth.getUsername(), auth.getPassword(), auth.getEmail()); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java index e04f4c9cb10e..6cbd9e2ac8d0 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java @@ -94,6 +94,11 @@ public DockerConfiguration withBuilderRegistryUserAuthentication(String username this.publishAuthentication, this.bindHostToBuilder); } + public DockerConfiguration withBuilderRegistryDefaultAuthentication() { + return new DockerConfiguration(this.host, new DefaultDockerRegistryAuthentication(), this.publishAuthentication, + this.bindHostToBuilder); + } + public DockerConfiguration withPublishRegistryTokenAuthentication(String token) { Assert.notNull(token, "'token' must not be null"); return new DockerConfiguration(this.host, this.builderAuthentication, @@ -113,6 +118,11 @@ public DockerConfiguration withEmptyPublishRegistryAuthentication() { new DockerRegistryUserAuthentication("", "", "", ""), this.bindHostToBuilder); } + public DockerConfiguration withPublishRegistryDefaultAuthentication() { + return new DockerConfiguration(this.host, this.builderAuthentication, new DefaultDockerRegistryAuthentication(), + this.bindHostToBuilder); + } + public static class DockerHostConfiguration { private final String address; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java index 9ab0fef20192..8613ebfb4257 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,11 @@ import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Collections; import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -148,15 +152,50 @@ static final class DockerConfig extends MappedObject { private final String currentContext; + private final String credsStore; + + private final Map credHelpers; + + private final Map auths; + private DockerConfig(JsonNode node) { super(node, MethodHandles.lookup()); this.currentContext = valueAt("/currentContext", String.class); + this.credsStore = valueAt("/credsStore", String.class); + this.credHelpers = extractCredHelpers(); + this.auths = extractAuths(); + } + + private Map extractAuths() { + Map auths = new LinkedHashMap<>(); + getNode().at("/auths") + .fields() + .forEachRemaining((entry) -> auths.put(entry.getKey(), new Auth(entry.getValue()))); + return Map.copyOf(auths); + } + + @SuppressWarnings("unchecked") + private Map extractCredHelpers() { + Map credHelpers = valueAt("/credHelpers", Map.class); + return (credHelpers != null) ? Map.copyOf(credHelpers) : Collections.emptyMap(); } String getCurrentContext() { return this.currentContext; } + String getCredsStore() { + return this.credsStore; + } + + Map getCredHelpers() { + return this.credHelpers; + } + + Map getAuths() { + return this.auths; + } + static DockerConfig fromJson(String json) throws JsonProcessingException { return new DockerConfig(SharedObjectMapper.get().readTree(json)); } @@ -167,6 +206,45 @@ static DockerConfig empty() { } + static final class Auth extends MappedObject { + + private final String username; + + private final String password; + + private final String email; + + Auth(JsonNode node) { + super(node, MethodHandles.lookup()); + String username = valueAt("/username", String.class); + String password = valueAt("/password", String.class); + String auth = valueAt("/auth", String.class); + if (auth != null) { + String[] parts = new String(Base64.getDecoder().decode(auth)).split(":", 2); + if (parts.length == 2) { + username = parts[0]; + password = parts[1]; + } + } + this.username = username; + this.password = password; + this.email = valueAt("/email", String.class); + } + + String getUsername() { + return this.username; + } + + String getPassword() { + return this.password; + } + + String getEmail() { + return this.email; + } + + } + static final class DockerContext extends MappedObject { private final String dockerHost; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerCredentialHelper.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerCredentialHelper.java new file mode 100644 index 000000000000..e424b05dd8ea --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerCredentialHelper.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import java.io.IOException; + +/** + * Docker credential helper used to retrieve credentials for servers. + * + * @author Dmytro Nosan + */ +interface DockerCredentialHelper { + + /** + * Retrieves the credential associated with the specified URL. + * @param serverUrl the server URL for which the credential is requested + * @return the {@link Credentials} containing authentication information for the given + * server, or {@code null} if no credential is available for the given server. + * @throws IOException if an I/O error occurs while retrieving the credential. + */ + Credentials get(String serverUrl) throws IOException; + + /** + * Creates a {@link DockerCredentialHelper} instance using the specified suffix. + * @param suffix the suffix of the credential helper, for example {@code gcr}, + * {@code ecr-login}, {@code desktop}, {@code osxkeychain}, etc. + * @return a {@link DefaultDockerCredentialHelper} instance, with the full name of the + * helper. e.g., {@code docker-credential-gcr} + */ + static DockerCredentialHelper ofSuffix(String suffix) { + return new DefaultDockerCredentialHelper("docker-credential-" + suffix.trim()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java index 3df4b4fadcbd..fc181dee4f20 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package org.springframework.boot.buildpack.platform.docker.configuration; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; + /** * Docker registry authentication configuration. * @@ -27,7 +29,26 @@ public interface DockerRegistryAuthentication { /** * Returns the auth header that should be used for docker authentication. * @return the auth header + * @throws IllegalStateException if the auth header cannot be created + * @deprecated since 3.5.0 for removal in 4.0.0 in favor of + * {@link #getAuthHeader(ImageReference)} + * + */ + @Deprecated(since = "3.5.0", forRemoval = true) + default String getAuthHeader() { + throw new IllegalStateException("Use getAuthHeader(ImageReference) instead of getAuthHeader()"); + } + + /** + * Returns the auth header that should be used for docker authentication. + * @param imageReference the image reference + * @return the auth header + * @throws IllegalStateException if the auth header cannot be created + * @since 3.5.0 + * */ - String getAuthHeader(); + default String getAuthHeader(ImageReference imageReference) { + return getAuthHeader(); + } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java index 46b7bad145e8..e67be77d21f5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; /** @@ -35,6 +36,12 @@ class JsonEncodedDockerRegistryAuthentication implements DockerRegistryAuthentic private String authHeader; @Override + public String getAuthHeader(ImageReference imageReference) { + return this.authHeader; + } + + @Override + @SuppressWarnings("removal") public String getAuthHeader() { return this.authHeader; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java index 7cba312d72e5..281c439581e9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java @@ -142,11 +142,14 @@ void buildInvokesBuilderAndPublishesImage() throws Exception { .withPublishRegistryTokenAuthentication("publish token"); given(docker.image() .pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)), isNull(), any(), - eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))) + eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF))))) .willAnswer(withPulledImage(builderImage)); given(docker.image() .pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), eq(ImagePlatform.from(builderImage)), - any(), eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))) + any(), + eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))) .willAnswer(withPulledImage(runImage)); Builder builder = new Builder(BuildLog.to(out), docker, dockerConfiguration); BuildRequest request = getTestRequest().withPublish(true); @@ -156,13 +159,15 @@ void buildInvokesBuilderAndPublishesImage() throws Exception { ArgumentCaptor archive = ArgumentCaptor.forClass(ImageArchive.class); then(docker.image()).should() .pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)), isNull(), any(), - eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())); + eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)))); then(docker.image()).should() .pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), eq(ImagePlatform.from(builderImage)), - any(), eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())); + any(), eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")))); then(docker.image()).should() .push(eq(request.getName()), any(), - eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())); + eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(request.getName()))); then(docker.image()).should().load(archive.capture(), any()); then(docker.image()).should().remove(archive.getValue().getTag(), true); then(docker.image()).shouldHaveNoMoreInteractions(); @@ -388,11 +393,14 @@ void buildInvokesBuilderWithTagsAndPublishesImageAndTags() throws Exception { .withPublishRegistryTokenAuthentication("publish token"); given(docker.image() .pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)), isNull(), any(), - eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))) + eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF))))) .willAnswer(withPulledImage(builderImage)); given(docker.image() .pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), eq(ImagePlatform.from(builderImage)), - any(), eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))) + any(), + eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))) .willAnswer(withPulledImage(runImage)); Builder builder = new Builder(BuildLog.to(out), docker, dockerConfiguration); BuildRequest request = getTestRequest().withPublish(true).withTags(ImageReference.of("my-application:1.2.3")); @@ -403,17 +411,20 @@ void buildInvokesBuilderWithTagsAndPublishesImageAndTags() throws Exception { then(docker.image()).should() .pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)), isNull(), any(), - eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())); + eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)))); then(docker.image()).should() .pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), eq(ImagePlatform.from(builderImage)), - any(), eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())); + any(), eq(dockerConfiguration.getBuilderRegistryAuthentication() + .getAuthHeader(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")))); then(docker.image()).should() .push(eq(request.getName()), any(), - eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())); + eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(request.getName()))); then(docker.image()).should().tag(eq(request.getName()), eq(ImageReference.of("my-application:1.2.3"))); then(docker.image()).should() .push(eq(ImageReference.of("my-application:1.2.3")), any(), - eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())); + eq(dockerConfiguration.getPublishRegistryAuthentication() + .getAuthHeader(ImageReference.of("my-application:1.2.3")))); ArgumentCaptor archive = ArgumentCaptor.forClass(ImageArchive.class); then(docker.image()).should().load(archive.capture(), any()); then(docker.image()).should().remove(archive.getValue().getTag(), true); @@ -487,42 +498,6 @@ void buildWhenBuilderReturnsErrorThrowsException() throws Exception { .withMessage("Builder lifecycle 'creator' failed with status code 9"); } - @Test - void buildWhenDetectedRunImageInDifferentAuthenticatedRegistryThrowsException() throws Exception { - TestPrintStream out = new TestPrintStream(); - DockerApi docker = mockDockerApi(); - Image builderImage = loadImage("image-with-run-image-different-registry.json"); - DockerConfiguration dockerConfiguration = new DockerConfiguration() - .withBuilderRegistryTokenAuthentication("builder token"); - given(docker.image() - .pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)), any(), any(), - eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))) - .willAnswer(withPulledImage(builderImage)); - Builder builder = new Builder(BuildLog.to(out), docker, dockerConfiguration); - BuildRequest request = getTestRequest(); - assertThatIllegalStateException().isThrownBy(() -> builder.build(request)) - .withMessage( - "Run image 'example.com/custom/run:latest' must be pulled from the 'docker.io' authenticated registry"); - } - - @Test - void buildWhenRequestedRunImageInDifferentAuthenticatedRegistryThrowsException() throws Exception { - TestPrintStream out = new TestPrintStream(); - DockerApi docker = mockDockerApi(); - Image builderImage = loadImage("image.json"); - DockerConfiguration dockerConfiguration = new DockerConfiguration() - .withBuilderRegistryTokenAuthentication("builder token"); - given(docker.image() - .pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_REF)), any(), any(), - eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))) - .willAnswer(withPulledImage(builderImage)); - Builder builder = new Builder(BuildLog.to(out), docker, dockerConfiguration); - BuildRequest request = getTestRequest().withRunImage(ImageReference.of("example.com/custom/run:latest")); - assertThatIllegalStateException().isThrownBy(() -> builder.build(request)) - .withMessage( - "Run image 'example.com/custom/run:latest' must be pulled from the 'docker.io' authenticated registry"); - } - @Test void buildWhenRequestedBuildpackNotInBuilderThrowsException() throws Exception { TestPrintStream out = new TestPrintStream(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/CredentialsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/CredentialsTests.java new file mode 100644 index 000000000000..6a7a8d197103 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/CredentialsTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import java.io.IOException; +import java.io.InputStream; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; +import org.springframework.boot.testsupport.classpath.resources.WithResource; +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Credentials}. + * + * @author Dmytro Nosan + */ +class CredentialsTests { + + @Test + @WithResource(name = "credentials.json", content = """ + { + "ServerURL": "https://index.docker.io/v1/", + "Username": "", + "Secret": "secret" + } + """) + void shouldCreateIdentityTokenCredentials() throws IOException { + Credentials credentials = getCredentials("credentials.json"); + assertThat(credentials.getUsername()).isEqualTo(""); + assertThat(credentials.getSecret()).isEqualTo("secret"); + assertThat(credentials.getServerUrl()).isEqualTo("https://index.docker.io/v1/"); + assertThat(credentials.isIdentityToken()).isTrue(); + } + + @Test + @WithResource(name = "credentials.json", content = """ + { + "ServerURL": "https://index.docker.io/v1/", + "Username": "user", + "Secret": "secret" + } + """) + void shouldCreateUsernamePasswordCredentials() throws IOException { + Credentials credentials = getCredentials("credentials.json"); + assertThat(credentials.getUsername()).isEqualTo("user"); + assertThat(credentials.getSecret()).isEqualTo("secret"); + assertThat(credentials.getServerUrl()).isEqualTo("https://index.docker.io/v1/"); + assertThat(credentials.isIdentityToken()).isFalse(); + } + + private Credentials getCredentials(String name) throws IOException { + try (InputStream inputStream = new ClassPathResource(name).getInputStream()) { + return new Credentials(SharedObjectMapper.get().readTree(inputStream)); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerCredentialHelperTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerCredentialHelperTests.java new file mode 100644 index 000000000000..6c6a6e0cd778 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerCredentialHelperTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import java.io.IOException; +import java.util.UUID; + +import com.sun.jna.Platform; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link DefaultDockerCredentialHelper}. + * + * @author Dmytro Nosan + */ +class DefaultDockerCredentialHelperTests { + + private DefaultDockerCredentialHelper helper; + + @BeforeEach + void setUp() throws IOException { + String name = "docker-credential-test"; + if (Platform.isWindows()) { + name += ".bat"; + } + this.helper = new DefaultDockerCredentialHelper( + new ClassPathResource(name, getClass()).getFile().getAbsolutePath()); + } + + @Test + void shouldReturnCredentialsForUser() throws IOException { + Credentials credentials = this.helper.get("user.example.com"); + assertThat(credentials).isNotNull(); + assertThat(credentials.isIdentityToken()).isFalse(); + assertThat(credentials.getServerUrl()).isEqualTo("user.example.com"); + assertThat(credentials.getUsername()).isEqualTo("username"); + assertThat(credentials.getSecret()).isEqualTo("secret"); + } + + @Test + void shouldReturnCredentialsForToken() throws IOException { + Credentials credentials = this.helper.get("token.example.com"); + assertThat(credentials).isNotNull(); + assertThat(credentials.isIdentityToken()).isTrue(); + assertThat(credentials.getServerUrl()).isEqualTo("token.example.com"); + assertThat(credentials.getUsername()).isEqualTo(""); + assertThat(credentials.getSecret()).isEqualTo("secret"); + } + + @Test + void shouldReturnNullCredentialsWhenCredentialsNotFoundError() throws IOException { + Credentials credentials = this.helper.get("credentials.missing.example.com"); + assertThat(credentials).isNull(); + } + + @Test + void shouldReturnNullCredentialsWhenUsernameMissingError() throws IOException { + Credentials credentials = this.helper.get("username.missing.example.com"); + assertThat(credentials).isNull(); + } + + @Test + void shouldReturnNullCredentialsWhenServerUrlMissingError() throws IOException { + Credentials credentials = this.helper.get("url.missing.example.com"); + assertThat(credentials).isNull(); + } + + @Test + void shouldThrowIOExceptionWhenUnknownError() { + assertThatIOException().isThrownBy(() -> this.helper.get("invalid.example.com")) + .withMessageContaining("Unknown error"); + } + + @Test + void shouldThrowIOExceptionWhenCommandDoesNotExist() { + String name = "docker-credential-%s".formatted(UUID.randomUUID().toString()); + assertThatIOException().isThrownBy(() -> new DefaultDockerCredentialHelper(name).get("invalid.example.com")) + .withMessageContaining(name); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerRegistryAuthenticationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerRegistryAuthenticationTests.java new file mode 100644 index 000000000000..abc163e4938f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DefaultDockerRegistryAuthenticationTests.java @@ -0,0 +1,340 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; +import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; +import org.springframework.boot.testsupport.classpath.resources.ResourcesRoot; +import org.springframework.boot.testsupport.classpath.resources.WithResource; +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link DefaultDockerRegistryAuthentication}. + * + * @author Dmytro Nosan + */ +@ExtendWith(OutputCaptureExtension.class) +class DefaultDockerRegistryAuthenticationTests { + + private final Map environment = new LinkedHashMap<>(); + + private final Map dockerCredentialHelpers = new LinkedHashMap<>(); + + private final DefaultDockerRegistryAuthentication authentication = new DefaultDockerRegistryAuthentication( + this.environment::get, this.dockerCredentialHelpers::get); + + @WithResource(name = "config.json", content = """ + { + "auths": { + "https://index.docker.io/v1/": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", + "email": "test@gmail.com" + } + } + } + """) + @Test + void shouldCreateAuthHeaderFromAuthForDockerDomain(@ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "https://index.docker.io/v1/") + .containsEntry("username", "username") + .containsEntry("password", "password") + .containsEntry("email", "test@gmail.com"); + } + + @WithResource(name = "config.json", content = """ + { + "auths": { + "https://index.docker.io/v1/": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", + "email": "test@gmail.com" + } + } + } + """) + @Test + void shouldCreateAuthHeaderFromAuthForLegacyDockerDomain(@ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("index.docker.io/ubuntu:latest"); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "https://index.docker.io/v1/") + .containsEntry("username", "username") + .containsEntry("password", "password") + .containsEntry("email", "test@gmail.com"); + } + + @WithResource(name = "config.json", content = """ + { + "auths": { + "my-registry.example.com": { + "auth": "Y3VzdG9tVXNlcjpjdXN0b21QYXNz" + } + } + } + """) + @Test + void shouldCreateAuthHeaderFromAuthForCustomDomain(@ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest"); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "my-registry.example.com") + .containsEntry("username", "customUser") + .containsEntry("password", "customPass") + .containsEntry("email", null); + } + + @WithResource(name = "config.json", content = """ + { + "auths": { + "https://my-registry.example.com": { + "auth": "Y3VzdG9tVXNlcjpjdXN0b21QYXNz" + } + } + } + """) + @Test + void shouldCreateAuthHeaderFromAuthForCustomDomainWithLegacyFormat(@ResourcesRoot Path directory) + throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest"); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "https://my-registry.example.com") + .containsEntry("username", "customUser") + .containsEntry("password", "customPass") + .containsEntry("email", null); + } + + @WithResource(name = "config.json", content = """ + { + } + """) + @Test + void shouldCreateAuthHeaderFromEmptyCredentialsWhenEmptyConfig(@ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "") + .containsEntry("username", "") + .containsEntry("password", "") + .containsEntry("email", ""); + } + + @WithResource(name = "config.json", content = """ + { + "credsStore": "desktop" + } + """) + @WithResource(name = "credentials.json", content = """ + { + "ServerURL": "https://index.docker.io/v1/", + "Username": "", + "Secret": "secret" + } + """) + @Test + void shouldCreateAuthHeaderFromCredsStore(@ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); + DockerCredentialHelper helper = mock(DockerCredentialHelper.class); + this.dockerCredentialHelpers.put("desktop", helper); + given(helper.get("https://index.docker.io/v1/")).willReturn(getCredentials("credentials.json")); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(1).containsEntry("identitytoken", "secret"); + } + + @WithResource(name = "config.json", content = """ + { + "auths": { + "gcr.io": { + "email": "test@gmail.com" + } + }, + "credsStore": "desktop", + "credHelpers": { + "gcr.io": "gcr" + } + } + """) + @WithResource(name = "credentials.json", content = """ + { + "ServerURL": "https://my-gcr.io", + "Username": "username", + "Secret": "secret" + } + """) + @Test + void shouldCreateAuthHeaderFromCredHelperAndUseEmailFromAuth(@ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); + DockerCredentialHelper helper = mock(DockerCredentialHelper.class); + this.dockerCredentialHelpers.put("gcr", helper); + given(helper.get("gcr.io")).willReturn(getCredentials("credentials.json")); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "https://my-gcr.io") + .containsEntry("username", "username") + .containsEntry("password", "secret") + .containsEntry("email", "test@gmail.com"); + } + + @WithResource(name = "config.json", content = """ + { + "credsStore": "desktop", + "credHelpers": { + "gcr.io": "gcr" + } + } + """) + @WithResource(name = "credentials.json", content = """ + { + "Username": "username", + "Secret": "secret" + } + """) + @Test + void shouldCreateAuthHeaderFromCredHelperAndUseProvidedServerUrl(@ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); + DockerCredentialHelper helper = mock(DockerCredentialHelper.class); + this.dockerCredentialHelpers.put("gcr", helper); + given(helper.get("gcr.io")).willReturn(getCredentials("credentials.json")); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "gcr.io") + .containsEntry("username", "username") + .containsEntry("password", "secret") + .containsEntry("email", null); + } + + @WithResource(name = "config.json", content = """ + { + "auths": { + "gcr.io": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", + "email": "test@gmail.com" + } + }, + "credsStore": "desktop", + "credHelpers": { + "gcr.io": "gcr" + } + } + """) + @Test + void shouldCreateHeaderFromAuthWhenFailedToGetCredentials(@ResourcesRoot Path directory, CapturedOutput output) + throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); + DockerCredentialHelper helper = mock(DockerCredentialHelper.class); + this.dockerCredentialHelpers.put("gcr", helper); + given(helper.get("gcr.io")).willThrow(new IOException("Failed to obtain credentials for registry")); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(output.getErr()) + .contains("Error retrieving credentials for 'gcr.io' due to: Failed to obtain credentials for registry"); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "gcr.io") + .containsEntry("username", "username") + .containsEntry("password", "password") + .containsEntry("email", "test@gmail.com"); + } + + @WithResource(name = "config.json", content = """ + { + "credsStore": "desktop", + "credHelpers": { + "gcr.io": "gcr" + } + } + """) + @Test + void shouldCreateAuthHeaderFromEmptyCredentialsWhenFailedToGetCredentials(@ResourcesRoot Path directory, + CapturedOutput output) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); + DockerCredentialHelper helper = mock(DockerCredentialHelper.class); + this.dockerCredentialHelpers.put("gcr", helper); + given(helper.get("gcr.io")).willThrow(new IOException("Failed to obtain credentials for registry")); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(output.getErr()) + .contains("Error retrieving credentials for 'gcr.io' due to: Failed to obtain credentials for registry"); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "") + .containsEntry("username", "") + .containsEntry("password", "") + .containsEntry("email", ""); + } + + @WithResource(name = "config.json", content = """ + { + "credsStore": "desktop", + "credHelpers": { + "gcr.io": "" + } + } + """) + // The Docker CLI appears to prioritize the credential helper over the + // credential store, even when the helper is empty. + @Test + void shouldCreateAuthHeaderFromEmptyCredentialsWhenCredsHelperTakesPrecedenceOverCredsStoreAndNoAuth( + @ResourcesRoot Path directory) throws IOException { + this.environment.put("DOCKER_CONFIG", directory.toString()); + ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); + String authHeader = this.authentication.getAuthHeader(imageReference); + assertThat(decode(authHeader)).hasSize(4) + .containsEntry("serveraddress", "") + .containsEntry("username", "") + .containsEntry("password", "") + .containsEntry("email", ""); + } + + private Credentials getCredentials(String name) throws IOException { + try (InputStream inputStream = new ClassPathResource(name).getInputStream()) { + return new Credentials(SharedObjectMapper.get().readTree(inputStream)); + } + } + + private Map decode(String authHeader) throws IOException { + assertThat(authHeader).isNotNull(); + return SharedObjectMapper.get().readValue(Base64.getDecoder().decode(authHeader), new TypeReference<>() { + }); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java index b47bbaa3d80c..962b38443fa2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerConfig; import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerContext; import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; @@ -46,6 +47,9 @@ void configWithContextIsRead() throws Exception { this.environment.put("DOCKER_CONFIG", pathToResource("with-context/config.json")); DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); assertThat(config.getConfiguration().getCurrentContext()).isEqualTo("test-context"); + assertThat(config.getConfiguration().getAuths()).isEmpty(); + assertThat(config.getConfiguration().getCredHelpers()).isEmpty(); + assertThat(config.getConfiguration().getCredsStore()).isNull(); assertThat(config.getContext().getDockerHost()).isEqualTo("unix:///home/user/.docker/docker.sock"); assertThat(config.getContext().isTlsVerify()).isFalse(); assertThat(config.getContext().getTlsPath()).isNull(); @@ -56,6 +60,9 @@ void configWithoutContextIsRead() throws Exception { this.environment.put("DOCKER_CONFIG", pathToResource("without-context/config.json")); DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); assertThat(config.getConfiguration().getCurrentContext()).isNull(); + assertThat(config.getConfiguration().getAuths()).isEmpty(); + assertThat(config.getConfiguration().getCredHelpers()).isEmpty(); + assertThat(config.getConfiguration().getCredsStore()).isNull(); assertThat(config.getContext().getDockerHost()).isNull(); assertThat(config.getContext().isTlsVerify()).isFalse(); assertThat(config.getContext().getTlsPath()).isNull(); @@ -66,6 +73,9 @@ void configWithDefaultContextIsRead() throws Exception { this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); assertThat(config.getConfiguration().getCurrentContext()).isEqualTo("default"); + assertThat(config.getConfiguration().getAuths()).isEmpty(); + assertThat(config.getConfiguration().getCredHelpers()).isEmpty(); + assertThat(config.getConfiguration().getCredsStore()).isNull(); assertThat(config.getContext().getDockerHost()).isNull(); assertThat(config.getContext().isTlsVerify()).isFalse(); assertThat(config.getContext().getTlsPath()).isNull(); @@ -95,10 +105,38 @@ void configIsEmptyWhenConfigFileDoesNotExist() { this.environment.put("DOCKER_CONFIG", "docker-config-dummy-path"); DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); assertThat(config.getConfiguration().getCurrentContext()).isNull(); + assertThat(config.getConfiguration().getAuths()).isEmpty(); + assertThat(config.getConfiguration().getCredHelpers()).isEmpty(); + assertThat(config.getConfiguration().getCredsStore()).isNull(); assertThat(config.getContext().getDockerHost()).isNull(); assertThat(config.getContext().isTlsVerify()).isFalse(); } + @Test + void configWithAuthIsRead() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-auth/config.json")); + DockerConfigurationMetadata metadata = DockerConfigurationMetadata.from(this.environment::get); + DockerConfig configuration = metadata.getConfiguration(); + assertThat(configuration.getCredsStore()).isEqualTo("desktop"); + assertThat(configuration.getCredHelpers()).hasSize(3) + .containsEntry("azurecr.io", "acr-env") + .containsEntry("ecr.us-east-1.amazonaws.com", "ecr-login") + .containsEntry("gcr.io", "gcr"); + assertThat(configuration.getAuths()).hasSize(3).hasEntrySatisfying("https://index.docker.io/v1/", (auth) -> { + assertThat(auth.getUsername()).isEqualTo("username"); + assertThat(auth.getPassword()).isEqualTo("password"); + assertThat(auth.getEmail()).isEqualTo("test@gmail.com"); + }).hasEntrySatisfying("custom-registry.example.com", (auth) -> { + assertThat(auth.getUsername()).isEqualTo("customUser"); + assertThat(auth.getPassword()).isEqualTo("customPass"); + assertThat(auth.getEmail()).isNull(); + }).hasEntrySatisfying("my-registry.example.com", (auth) -> { + assertThat(auth.getUsername()).isEqualTo("user"); + assertThat(auth.getPassword()).isEqualTo("password"); + assertThat(auth.getEmail()).isNull(); + }); + } + private String pathToResource(String resource) throws URISyntaxException { URL url = getClass().getResource(resource); return Paths.get(url.toURI()).getParent().toAbsolutePath().toString(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerCredentialHelperTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerCredentialHelperTests.java new file mode 100644 index 000000000000..c9c45f11fce3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerCredentialHelperTests.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.configuration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DockerCredentialHelper}. + * + * @author Dmytro Nosan + */ +class DockerCredentialHelperTests { + + @Test + void shouldCreateDockerCredentialOfSuffix() { + DockerCredentialHelper helper = DockerCredentialHelper.ofSuffix("desktop"); + assertThat(helper).hasFieldOrPropertyWithValue("name", "docker-credential-desktop"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java index c2a855977fd5..46ccd64e3579 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; import org.springframework.util.StreamUtils; @@ -37,7 +38,7 @@ class DockerRegistryTokenAuthenticationTests extends AbstractJsonTests { @Test void createAuthHeaderReturnsEncodedHeader() throws IOException, JSONException { DockerRegistryTokenAuthentication auth = new DockerRegistryTokenAuthentication("tokenvalue"); - String header = auth.getAuthHeader(); + String header = auth.getAuthHeader(ImageReference.of("ubuntu:18.04")); String expectedJson = StreamUtils.copyToString(getContent("auth-token.json"), StandardCharsets.UTF_8); JSONAssert.assertEquals(expectedJson, new String(Base64.getUrlDecoder().decode(header)), true); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java index 0bd78f70dcad..04999a7f9e08 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; import org.springframework.util.StreamUtils; @@ -34,17 +35,21 @@ */ class DockerRegistryUserAuthenticationTests extends AbstractJsonTests { + private final ImageReference imageReference = ImageReference.of("ubuntu:18.04"); + @Test void createMinimalAuthHeaderReturnsEncodedHeader() throws IOException, JSONException { DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret", "https://docker.example.com", "docker@example.com"); - JSONAssert.assertEquals(jsonContent("auth-user-full.json"), decoded(auth.getAuthHeader()), true); + JSONAssert.assertEquals(jsonContent("auth-user-full.json"), decoded(auth.getAuthHeader(this.imageReference)), + true); } @Test void createFullAuthHeaderReturnsEncodedHeader() throws IOException, JSONException { DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret", null, null); - JSONAssert.assertEquals(jsonContent("auth-user-minimal.json"), decoded(auth.getAuthHeader()), false); + JSONAssert.assertEquals(jsonContent("auth-user-minimal.json"), decoded(auth.getAuthHeader(this.imageReference)), + false); } private String jsonContent(String s) throws IOException { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/docker-credential-test b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/docker-credential-test new file mode 100755 index 000000000000..d69879398c17 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/docker-credential-test @@ -0,0 +1,43 @@ +#!/bin/sh + +read -r registryUrl + +if [ "$registryUrl" = "user.example.com" ]; then + cat <", + "Secret": "secret" +} +EOF + exit 0 +fi + +if [ "$registryUrl" = "url.missing.example.com" ]; then + echo "no credentials server URL" >&2 + exit 1 +fi + +if [ "$registryUrl" = "username.missing.example.com" ]; then + echo "no credentials username" >&2 + exit 1 +fi + +if [ "$registryUrl" = "credentials.missing.example.com" ]; then + echo "credentials not found in native keychain" >&2 + exit 1 +fi + +echo "Unknown error" >&2 +exit 1 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/docker-credential-test.bat b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/docker-credential-test.bat new file mode 100644 index 000000000000..ce47ef659d5d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/docker-credential-test.bat @@ -0,0 +1,39 @@ +@echo off + +set /p registryUrl= + +if "%registryUrl%" == "user.example.com" ( + echo { + echo "ServerURL": "%registryUrl%", + echo "Username": "username", + echo "Secret": "secret" + echo } + exit /b 0 +) + +if "%registryUrl%" == "token.example.com" ( + echo { + echo "ServerURL": "%registryUrl%", + echo "Username": "", + echo "Secret": "secret" + echo } + exit /b 0 +) + +if "%registryUrl%" == "url.missing.example.com" ( + echo no credentials server URL >&2 + exit /b 1 +) + +if "%registryUrl%" == "username.missing.example.com" ( + echo no credentials username >&2 + exit /b 1 +) + +if "%registryUrl%" == "credentials.missing.example.com" ( + echo credentials not found in native keychain >&2 + exit /b 1 +) + +echo Unknown error >&2 +exit /b 1 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-auth/config.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-auth/config.json new file mode 100644 index 000000000000..5ccd4b0bf21f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-auth/config.json @@ -0,0 +1,21 @@ +{ + "auths": { + "https://index.docker.io/v1/": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", + "email": "test@gmail.com" + }, + "custom-registry.example.com": { + "auth": "Y3VzdG9tVXNlcjpjdXN0b21QYXNz" + }, + "my-registry.example.com": { + "username": "user", + "password": "password" + } + }, + "credsStore": "desktop", + "credHelpers": { + "gcr.io": "gcr", + "ecr.us-east-1.amazonaws.com": "ecr-login", + "azurecr.io": "acr-env" + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc index 2f3026504ec7..77c9dc8c8335 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc @@ -100,6 +100,14 @@ The following table summarizes the available properties for `docker.builderRegis For more details, see also xref:packaging-oci-image.adoc#build-image.examples.docker[examples]. +[NOTE] +==== +By default, *if credentials are not provided*, the plugin reads the user's existing Docker configuration file (typically located at `$HOME/.docker/config.json`) to determine authentication methods. Using these methods, the plugin attempts to provide authentication credentials for the requested image. The plugin supports the following authentication methods: + +- *Credential Helpers*: External tools configured in the Docker configuration file to provide credentials for specific registries. For example, tools like `osxkeychain` or `ecr-login` handle authentication for certain registries. +- *Credential Store*: A default fallback mechanism that securely stores and retrieves credentials (e.g., `desktop` for Docker Desktop). +- *Static Credentials*: Credentials that are stored directly in the Docker configuration file under the `auths` section. +==== [[build-image.customization]] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java index ffed3ddba17c..b436b42195fa 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -145,7 +145,7 @@ private DockerConfiguration customizeHost(DockerConfiguration dockerConfiguratio private DockerConfiguration customizeBuilderAuthentication(DockerConfiguration dockerConfiguration) { if (this.builderRegistry == null || this.builderRegistry.hasEmptyAuth()) { - return dockerConfiguration; + return dockerConfiguration.withBuilderRegistryDefaultAuthentication(); } if (this.builderRegistry.hasTokenAuth() && !this.builderRegistry.hasUserAuth()) { return dockerConfiguration.withBuilderRegistryTokenAuthentication(this.builderRegistry.getToken().get()); @@ -161,7 +161,7 @@ private DockerConfiguration customizeBuilderAuthentication(DockerConfiguration d private DockerConfiguration customizePublishAuthentication(DockerConfiguration dockerConfiguration) { if (this.publishRegistry == null || this.publishRegistry.hasEmptyAuth()) { - return dockerConfiguration.withEmptyPublishRegistryAuthentication(); + return dockerConfiguration.withPublishRegistryDefaultAuthentication(); } if (this.publishRegistry.hasTokenAuth() && !this.publishRegistry.hasUserAuth()) { return dockerConfiguration.withPublishRegistryTokenAuthentication(this.publishRegistry.getToken().get()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java index 3252cedb2da0..025a9da4b77e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,7 +26,9 @@ import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.gradle.junit.GradleProjectBuilder; +import org.springframework.util.ClassUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -39,6 +41,8 @@ */ class DockerSpecTests { + private final ImageReference imageReference = ImageReference.of("ubuntu:18.04"); + private DockerSpec dockerSpec; @BeforeEach @@ -54,12 +58,10 @@ void prepareDockerSpec(@TempDir File temp) { void asDockerConfigurationWithDefaults() { DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration(); assertThat(dockerConfiguration.getHost()).isNull(); - assertThat(dockerConfiguration.getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(dockerConfiguration.getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); } @Test @@ -74,12 +76,10 @@ void asDockerConfigurationWithHostConfiguration() { assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); assertThat(host.getContext()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); - assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(dockerConfiguration.getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); } @Test @@ -92,12 +92,10 @@ void asDockerConfigurationWithHostConfigurationNoTlsVerify() { assertThat(host.getCertificatePath()).isNull(); assertThat(host.getContext()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); - assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); } @Test @@ -110,12 +108,10 @@ void asDockerConfigurationWithContextConfiguration() { assertThat(host.isSecure()).isFalse(); assertThat(host.getCertificatePath()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); - assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); } @Test @@ -136,12 +132,10 @@ void asDockerConfigurationWithBindHostToBuilder() { assertThat(host.isSecure()).isFalse(); assertThat(host.getCertificatePath()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isTrue(); - assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthentication()); } @Test @@ -159,12 +153,12 @@ void asDockerConfigurationWithUserAuth() { registry.getEmail().set("docker2@example.com"); }); DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration(); - assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"username\" : \"user1\"") .contains("\"password\" : \"secret1\"") .contains("\"email\" : \"docker1@example.com\"") .contains("\"serveraddress\" : \"https://docker1.example.com\""); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"username\" : \"user2\"") .contains("\"password\" : \"secret2\"") .contains("\"email\" : \"docker2@example.com\"") @@ -199,9 +193,9 @@ void asDockerConfigurationWithTokenAuth() { this.dockerSpec.builderRegistry((registry) -> registry.getToken().set("token1")); this.dockerSpec.publishRegistry((registry) -> registry.getToken().set("token2")); DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration(); - assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"identitytoken\" : \"token1\""); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"identitytoken\" : \"token2\""); } @@ -220,4 +214,10 @@ String decoded(String value) { return new String(Base64.getDecoder().decode(value)); } + private Class getDefaultDockerRegistryAuthentication() { + return ClassUtils.resolveClassName( + "org.springframework.boot.buildpack.platform.docker.configuration.DefaultDockerRegistryAuthentication", + getClass().getClassLoader()); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc index 66d108c82947..b60ff4093dfa 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc @@ -115,7 +115,14 @@ The following table summarizes the available parameters for `docker.builderRegis For more details, see also xref:build-image.adoc#build-image.examples.docker[examples]. - +[NOTE] +==== +By default, *if credentials are not provided*, the plugin reads the user's existing Docker configuration file (typically located at `$HOME/.docker/config.json`) to determine authentication methods. Using these methods, the plugin attempts to provide authentication credentials for the requested image. The plugin supports the following authentication methods: + +- *Credential Helpers*: External tools configured in the Docker configuration file to provide credentials for specific registries. For example, tools like `osxkeychain` or `ecr-login` handle authentication for certain registries. +- *Credential Store*: A default fallback mechanism that securely stores and retrieves credentials (e.g., `desktop` for Docker Desktop). +- *Static Credentials*: Credentials that are stored directly in the Docker configuration file under the `auths` section. +==== [[build-image.customization]] == Image Customizations diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java index a28403a179c9..7e1b44233b8b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -168,7 +168,7 @@ private DockerConfiguration customizeHost(DockerConfiguration dockerConfiguratio private DockerConfiguration customizeBuilderAuthentication(DockerConfiguration dockerConfiguration) { if (this.builderRegistry == null || this.builderRegistry.isEmpty()) { - return dockerConfiguration; + return dockerConfiguration.withBuilderRegistryDefaultAuthentication(); } if (this.builderRegistry.hasTokenAuth() && !this.builderRegistry.hasUserAuth()) { return dockerConfiguration.withBuilderRegistryTokenAuthentication(this.builderRegistry.getToken()); @@ -187,7 +187,7 @@ private DockerConfiguration customizePublishAuthentication(DockerConfiguration d return dockerConfiguration; } if (this.publishRegistry == null || this.publishRegistry.isEmpty()) { - return dockerConfiguration.withEmptyPublishRegistryAuthentication(); + return dockerConfiguration.withPublishRegistryDefaultAuthentication(); } if (this.publishRegistry.hasTokenAuth() && !this.publishRegistry.hasUserAuth()) { return dockerConfiguration.withPublishRegistryTokenAuthentication(this.publishRegistry.getToken()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java index 65a162d8b3c7..5928e8d60a49 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,8 @@ import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; +import org.springframework.util.ClassUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -34,17 +36,17 @@ */ class DockerTests { + private final ImageReference imageReference = ImageReference.of("ubuntu:22.04"); + @Test void asDockerConfigurationWithDefaults() { Docker docker = new Docker(); DockerConfiguration dockerConfiguration = createDockerConfiguration(docker); assertThat(dockerConfiguration.getHost()).isNull(); - assertThat(dockerConfiguration.getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(dockerConfiguration.getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); } @Test @@ -60,12 +62,10 @@ void asDockerConfigurationWithHostConfiguration() { assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); assertThat(host.getContext()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); - assertThat(createDockerConfiguration(docker).getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(dockerConfiguration.getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); } @Test @@ -79,12 +79,10 @@ void asDockerConfigurationWithContextConfiguration() { assertThat(host.isSecure()).isFalse(); assertThat(host.getCertificatePath()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); - assertThat(createDockerConfiguration(docker).getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(dockerConfiguration.getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); } @Test @@ -109,12 +107,10 @@ void asDockerConfigurationWithBindHostToBuilder() { assertThat(host.isSecure()).isTrue(); assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); assertThat(dockerConfiguration.isBindHostToBuilder()).isTrue(); - assertThat(createDockerConfiguration(docker).getBuilderRegistryAuthentication()).isNull(); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) - .contains("\"username\" : \"\"") - .contains("\"password\" : \"\"") - .contains("\"email\" : \"\"") - .contains("\"serveraddress\" : \"\""); + assertThat(createDockerConfiguration(docker).getBuilderRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); + assertThat(dockerConfiguration.getPublishRegistryAuthentication()) + .isInstanceOf(getDefaultDockerRegistryAuthenticationClass()); } @Test @@ -125,12 +121,12 @@ void asDockerConfigurationWithUserAuth() { docker.setPublishRegistry( new Docker.DockerRegistry("user2", "secret2", "https://docker2.example.com", "docker2@example.com")); DockerConfiguration dockerConfiguration = createDockerConfiguration(docker); - assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"username\" : \"user1\"") .contains("\"password\" : \"secret1\"") .contains("\"email\" : \"docker1@example.com\"") .contains("\"serveraddress\" : \"https://docker1.example.com\""); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"username\" : \"user2\"") .contains("\"password\" : \"secret2\"") .contains("\"email\" : \"docker2@example.com\"") @@ -170,9 +166,9 @@ void asDockerConfigurationWithTokenAuth() { docker.setBuilderRegistry(new Docker.DockerRegistry("token1")); docker.setPublishRegistry(new Docker.DockerRegistry("token2")); DockerConfiguration dockerConfiguration = createDockerConfiguration(docker); - assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"identitytoken\" : \"token1\""); - assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) + assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(this.imageReference))) .contains("\"identitytoken\" : \"token2\""); } @@ -205,6 +201,12 @@ private DockerConfiguration createDockerConfiguration(Docker docker) { } + private Class getDefaultDockerRegistryAuthenticationClass() { + return ClassUtils.resolveClassName( + "org.springframework.boot.buildpack.platform.docker.configuration.DefaultDockerRegistryAuthentication", + getClass().getClassLoader()); + } + String decoded(String value) { return new String(Base64.getDecoder().decode(value)); }