Skip to content

Add support for Docker's credential stores and helpers #45269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -203,64 +203,61 @@ private void pushImages(ImageReference name, List<ImageReference> tags) throws I
private void pushImage(ImageReference reference) throws IOException {
Consumer<TotalProgressEvent> 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;
}

/**
* Internal utility class used to fetch images.
*/
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;
}

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);
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <token>}.
*/
private static final String TOKEN_USERNAME = "<token>";

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;
}

}
Original file line number Diff line number Diff line change
@@ -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<String> 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;
};
}

}
Loading