Add support for publishing docker images to a registry

This commit adds options to the Maven and Gradle plugins to publish
to a Docker registry the image generated by the image-building goal
and task.

The Docker registry auth configuration added in an earlier commit
was modified to accept separate auth configs for the builder/run
image and the generated image, since it is likely these images will
be stored in separate registries or repositories with distinct
auth required for each.

Fixes gh-21001
This commit is contained in:
Scott Frederick
2020-09-18 17:59:10 -05:00
parent 8b740c07e3
commit 09b627d232
61 changed files with 1648 additions and 444 deletions

View File

@@ -75,6 +75,16 @@ public abstract class AbstractBuildLog implements BuildLog {
log(String.format(" > Pulled %s '%s'", imageType.getDescription(), getDigest(image)));
}
@Override
public Consumer<TotalProgressEvent> pushingImage(ImageReference imageReference) {
return getProgressConsumer(String.format(" > Pushing image '%s'", imageReference));
}
@Override
public void pushedImage(ImageReference imageReference) {
log(String.format(" > Pushed image '%s'", imageReference));
}
@Override
public void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume) {
log(" > Executing lifecycle version " + version);

View File

@@ -92,11 +92,24 @@ public interface BuildLog {
/**
* Log that an image has been pulled.
* @param image the builder image that was pulled
* @param image the image that was pulled
* @param imageType the image type that was pulled
*/
void pulledImage(Image image, ImageType imageType);
/**
* Log that an image is being pushed.
* @param imageReference the image reference
* @return a consumer for progress update events
*/
Consumer<TotalProgressEvent> pushingImage(ImageReference imageReference);
/**
* Log that an image has been pushed.
* @param imageReference the image reference
*/
void pushedImage(ImageReference imageReference);
/**
* Log that the lifecycle is executing.
* @param request the build request

View File

@@ -59,6 +59,8 @@ public class BuildRequest {
private final PullPolicy pullPolicy;
private final boolean publish;
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) {
Assert.notNull(name, "Name must not be null");
Assert.notNull(applicationContent, "ApplicationContent must not be null");
@@ -70,12 +72,13 @@ public class BuildRequest {
this.cleanCache = false;
this.verboseLogging = false;
this.pullPolicy = PullPolicy.ALWAYS;
this.publish = false;
this.creator = Creator.withVersion("");
}
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder,
ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache,
boolean verboseLogging, PullPolicy pullPolicy) {
boolean verboseLogging, PullPolicy pullPolicy, boolean publish) {
this.name = name;
this.applicationContent = applicationContent;
this.builder = builder;
@@ -85,6 +88,7 @@ public class BuildRequest {
this.cleanCache = cleanCache;
this.verboseLogging = verboseLogging;
this.pullPolicy = pullPolicy;
this.publish = publish;
}
/**
@@ -95,7 +99,7 @@ public class BuildRequest {
public BuildRequest withBuilder(ImageReference builder) {
Assert.notNull(builder, "Builder must not be null");
return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage,
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy);
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
@@ -105,7 +109,7 @@ public class BuildRequest {
*/
public BuildRequest withRunImage(ImageReference runImageName) {
return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(),
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy);
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
@@ -116,7 +120,7 @@ public class BuildRequest {
public BuildRequest withCreator(Creator creator) {
Assert.notNull(creator, "Creator must not be null");
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy);
this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
@@ -131,7 +135,7 @@ public class BuildRequest {
Map<String, String> env = new LinkedHashMap<>(this.env);
env.put(name, value);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy);
Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
@@ -144,7 +148,8 @@ public class BuildRequest {
Map<String, String> updatedEnv = new LinkedHashMap<>(this.env);
updatedEnv.putAll(env);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy);
Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy,
this.publish);
}
/**
@@ -154,7 +159,7 @@ public class BuildRequest {
*/
public BuildRequest withCleanCache(boolean cleanCache) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
cleanCache, this.verboseLogging, this.pullPolicy);
cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
@@ -164,7 +169,7 @@ public class BuildRequest {
*/
public BuildRequest withVerboseLogging(boolean verboseLogging) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, verboseLogging, this.pullPolicy);
this.cleanCache, verboseLogging, this.pullPolicy, this.publish);
}
/**
@@ -174,7 +179,17 @@ public class BuildRequest {
*/
public BuildRequest withPullPolicy(PullPolicy pullPolicy) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, pullPolicy);
this.cleanCache, this.verboseLogging, pullPolicy, this.publish);
}
/**
* Return a new {@link BuildRequest} with an updated publish setting.
* @param publish if the built image should be pushed to a registry
* @return an updated build request
*/
public BuildRequest withPublish(boolean publish) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy, publish);
}
/**
@@ -244,6 +259,14 @@ public class BuildRequest {
return this.verboseLogging;
}
/**
* Return if the built image should be pushed to a registry.
* @return if the built image should be pushed to a registry
*/
public boolean isPublish() {
return this.publish;
}
/**
* Return the image {@link PullPolicy} that the builder should use.
* @return image pull policy

View File

@@ -23,6 +23,7 @@ import org.springframework.boot.buildpack.platform.build.BuilderMetadata.Stack;
import org.springframework.boot.buildpack.platform.docker.DockerApi;
import org.springframework.boot.buildpack.platform.docker.TotalProgressEvent;
import org.springframework.boot.buildpack.platform.docker.TotalProgressPullListener;
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.transport.DockerEngineException;
@@ -45,6 +46,8 @@ public class Builder {
private final DockerApi docker;
private final DockerConfiguration dockerConfiguration;
/**
* Create a new builder instance.
*/
@@ -66,7 +69,7 @@ public class Builder {
* @param log a logger used to record output
*/
public Builder(BuildLog log) {
this(log, new DockerApi());
this(log, new DockerApi(), null);
}
/**
@@ -76,13 +79,14 @@ public class Builder {
* @since 2.4.0
*/
public Builder(BuildLog log, DockerConfiguration dockerConfiguration) {
this(log, new DockerApi(dockerConfiguration));
this(log, new DockerApi(dockerConfiguration), dockerConfiguration);
}
Builder(BuildLog log, DockerApi docker) {
Builder(BuildLog log, DockerApi docker, DockerConfiguration dockerConfiguration) {
Assert.notNull(log, "Log must not be null");
this.log = log;
this.docker = docker;
this.dockerConfiguration = dockerConfiguration;
}
public void build(BuildRequest request) throws DockerEngineException, IOException {
@@ -97,6 +101,9 @@ public class Builder {
this.docker.image().load(builder.getArchive(), UpdateListener.none());
try {
executeLifecycle(request, builder);
if (request.isPublish()) {
pushImage(request.getName());
}
}
finally {
this.docker.image().remove(builder.getName(), true);
@@ -143,11 +150,28 @@ public class Builder {
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
Consumer<TotalProgressEvent> progressConsumer = this.log.pullingImage(reference, imageType);
TotalProgressPullListener listener = new TotalProgressPullListener(progressConsumer);
Image image = this.docker.image().pull(reference, listener);
Image image = this.docker.image().pull(reference, listener, getBuilderAuthHeader());
this.log.pulledImage(image, imageType);
return image;
}
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.log.pushedImage(reference);
}
private String getBuilderAuthHeader() {
return (this.dockerConfiguration != null && this.dockerConfiguration.getBuilderRegistryAuthentication() != null)
? this.dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader() : null;
}
private String getPublishAuthHeader() {
return (this.dockerConfiguration != null && this.dockerConfiguration.getPublishRegistryAuthentication() != null)
? this.dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader() : null;
}
private void assertStackIdsMatch(Image runImage, Image builderImage) {
StackId runImageStackId = StackId.fromImage(runImage);
StackId builderImageStackId = StackId.fromImage(builderImage);

View File

@@ -78,7 +78,7 @@ public class DockerApi {
* @since 2.4.0
*/
public DockerApi(DockerConfiguration dockerConfiguration) {
this(HttpTransport.create(dockerConfiguration));
this(HttpTransport.create((dockerConfiguration != null) ? dockerConfiguration.getHost() : null));
}
/**
@@ -156,13 +156,26 @@ public class DockerApi {
* @throws IOException on IO error
*/
public Image pull(ImageReference reference, UpdateListener<PullImageUpdateEvent> listener) throws IOException {
return pull(reference, listener, null);
}
/**
* Pull an image from a registry.
* @param reference the image reference to pull
* @param listener a pull listener to receive update events
* @param registryAuth registry authentication credentials
* @return the {@link ImageApi pulled image} instance
* @throws IOException on IO error
*/
public Image pull(ImageReference reference, UpdateListener<PullImageUpdateEvent> listener, String registryAuth)
throws IOException {
Assert.notNull(reference, "Reference must not be null");
Assert.notNull(listener, "Listener must not be null");
URI createUri = buildUrl("/images/create", "fromImage", reference.toString());
DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener();
listener.onStart();
try {
try (Response response = http().post(createUri)) {
try (Response response = http().post(createUri, registryAuth)) {
jsonStream().get(response.getContent(), PullImageUpdateEvent.class, (event) -> {
digestCapture.onUpdate(event);
listener.onUpdate(event);
@@ -175,6 +188,33 @@ public class DockerApi {
}
}
/**
* Push an image to a registry.
* @param reference the image reference to push
* @param listener a push listener to receive update events
* @param registryAuth registry authentication credentials
* @throws IOException on IO error
*/
public void push(ImageReference reference, UpdateListener<PushImageUpdateEvent> listener, String registryAuth)
throws IOException {
Assert.notNull(reference, "Reference must not be null");
Assert.notNull(listener, "Listener must not be null");
URI pushUri = buildUrl("/images/" + reference + "/push");
ErrorCaptureUpdateListener errorListener = new ErrorCaptureUpdateListener();
listener.onStart();
try {
try (Response response = http().post(pushUri, registryAuth)) {
jsonStream().get(response.getContent(), PushImageUpdateEvent.class, (event) -> {
errorListener.onUpdate(event);
listener.onUpdate(event);
});
}
}
finally {
listener.onFinish();
}
}
/**
* Load an {@link ImageArchive} into Docker.
* @param archive the archive to load
@@ -398,4 +438,18 @@ public class DockerApi {
}
/**
* {@link UpdateListener} used to capture the details of an error in a response
* stream.
*/
private static class ErrorCaptureUpdateListener implements UpdateListener<PushImageUpdateEvent> {
@Override
public void onUpdate(PushImageUpdateEvent event) {
Assert.state(event.getErrorDetail() == null,
() -> "Error response received when pushing image: " + event.getErrorDetail().getMessage());
}
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2012-2020 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;
/**
* A {@link ProgressUpdateEvent} fired for image events.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.4.0
*/
public class ImageProgressUpdateEvent extends ProgressUpdateEvent {
private final String id;
protected ImageProgressUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) {
super(status, progressDetail, progress);
this.id = id;
}
/**
* Returns the ID of the image layer being updated if available.
* @return the ID of the updated layer or {@code null}
*/
public String getId() {
return this.id;
}
}

View File

@@ -22,24 +22,14 @@ import com.fasterxml.jackson.annotation.JsonCreator;
* A {@link ProgressUpdateEvent} fired as an image is pulled.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0
*/
public class PullImageUpdateEvent extends ProgressUpdateEvent {
private final String id;
public class PullImageUpdateEvent extends ImageProgressUpdateEvent {
@JsonCreator
public PullImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) {
super(status, progressDetail, progress);
this.id = id;
}
/**
* Return the ID of the layer being updated if available.
* @return the ID of the updated layer or {@code null}
*/
public String getId() {
return this.id;
super(id, status, progressDetail, progress);
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2012-2020 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;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* A {@link ProgressUpdateEvent} fired as an image is pushed to a registry.
*
* @author Scott Frederick
* @since 2.4.0
*/
public class PushImageUpdateEvent extends ImageProgressUpdateEvent {
private final ErrorDetail errorDetail;
@JsonCreator
public PushImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress,
ErrorDetail errorDetail) {
super(id, status, progressDetail, progress);
this.errorDetail = errorDetail;
}
/**
* Returns the details of any error encountered during processing.
* @return the error
*/
public ErrorDetail getErrorDetail() {
return this.errorDetail;
}
/**
* Details of an error embedded in a response stream.
*/
public static class ErrorDetail {
private final String message;
@JsonCreator
public ErrorDetail(@JsonProperty("message") String message) {
this.message = message;
}
/**
* Returns the message field from the error detail.
* @return the message
*/
public String getMessage() {
return this.message;
}
@Override
public String toString() {
return this.message;
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2012-2020 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;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.springframework.boot.buildpack.platform.docker.ProgressUpdateEvent.ProgressDetail;
/**
* {@link UpdateListener} that calculates the total progress of the entire image operation
* and publishes {@link TotalProgressEvent}.
*
* @param <E> the type of {@link ImageProgressUpdateEvent}
* @author Phillip Webb
* @author Scott Frederick
* @since 2.4.0
*/
public abstract class TotalProgressListener<E extends ImageProgressUpdateEvent> implements UpdateListener<E> {
private final Map<String, Layer> layers = new ConcurrentHashMap<>();
private final Consumer<TotalProgressEvent> consumer;
private final String[] trackedStatusKeys;
private boolean progressStarted;
/**
* Create a new {@link TotalProgressListener} that sends {@link TotalProgressEvent
* events} to the given consumer.
* @param consumer the consumer that receives {@link TotalProgressEvent progress
* events}
* @param trackedStatusKeys a list of status event keys to track the progress of
*/
protected TotalProgressListener(Consumer<TotalProgressEvent> consumer, String[] trackedStatusKeys) {
this.consumer = consumer;
this.trackedStatusKeys = trackedStatusKeys;
}
@Override
public void onStart() {
}
@Override
public void onUpdate(E event) {
if (event.getId() != null) {
this.layers.computeIfAbsent(event.getId(), (value) -> new Layer(this.trackedStatusKeys)).update(event);
}
this.progressStarted = this.progressStarted || event.getProgress() != null;
if (this.progressStarted) {
publish(0);
}
}
@Override
public void onFinish() {
this.layers.values().forEach(Layer::finish);
publish(100);
}
private void publish(int fallback) {
int count = 0;
int total = 0;
for (Layer layer : this.layers.values()) {
count++;
total += layer.getProgress();
}
TotalProgressEvent event = new TotalProgressEvent(
(count != 0) ? withinPercentageBounds(total / count) : fallback);
this.consumer.accept(event);
}
private static int withinPercentageBounds(int value) {
if (value < 0) {
return 0;
}
return Math.min(value, 100);
}
/**
* Progress for an individual layer.
*/
private static class Layer {
private final Map<String, Integer> progressByStatus = new HashMap<>();
Layer(String[] trackedStatusKeys) {
Arrays.stream(trackedStatusKeys).forEach((status) -> this.progressByStatus.put(status, 0));
}
void update(ImageProgressUpdateEvent event) {
String status = event.getStatus();
if (event.getProgressDetail() != null && this.progressByStatus.containsKey(status)) {
int current = this.progressByStatus.get(status);
this.progressByStatus.put(status, updateProgress(current, event.getProgressDetail()));
}
}
private int updateProgress(int current, ProgressDetail detail) {
int result = withinPercentageBounds((int) ((100.0 / detail.getTotal()) * detail.getCurrent()));
return Math.max(result, current);
}
void finish() {
this.progressByStatus.keySet().forEach((key) -> this.progressByStatus.put(key, 100));
}
int getProgress() {
return withinPercentageBounds((this.progressByStatus.values().stream().mapToInt(Integer::valueOf).sum())
/ this.progressByStatus.size());
}
}
}

View File

@@ -16,26 +16,19 @@
package org.springframework.boot.buildpack.platform.docker;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.springframework.boot.buildpack.platform.docker.ProgressUpdateEvent.ProgressDetail;
/**
* {@link UpdateListener} that calculates the total progress of the entire pull operation
* and publishes {@link TotalProgressEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0
*/
public class TotalProgressPullListener implements UpdateListener<PullImageUpdateEvent> {
public class TotalProgressPullListener extends TotalProgressListener<PullImageUpdateEvent> {
private final Map<String, Layer> layers = new ConcurrentHashMap<>();
private final Consumer<TotalProgressEvent> consumer;
private boolean progressStarted;
private static final String[] TRACKED_STATUS_KEYS = { "Downloading", "Extracting" };
/**
* Create a new {@link TotalProgressPullListener} that prints a progress bar to
@@ -53,87 +46,7 @@ public class TotalProgressPullListener implements UpdateListener<PullImageUpdate
* events}
*/
public TotalProgressPullListener(Consumer<TotalProgressEvent> consumer) {
this.consumer = consumer;
}
@Override
public void onStart() {
}
@Override
public void onUpdate(PullImageUpdateEvent event) {
if (event.getId() != null) {
this.layers.computeIfAbsent(event.getId(), Layer::new).update(event);
}
this.progressStarted = this.progressStarted || event.getProgress() != null;
if (this.progressStarted) {
publish(0);
}
}
@Override
public void onFinish() {
this.layers.values().forEach(Layer::finish);
publish(100);
}
private void publish(int fallback) {
int count = 0;
int total = 0;
for (Layer layer : this.layers.values()) {
count++;
total += layer.getProgress();
}
TotalProgressEvent event = new TotalProgressEvent(
(count != 0) ? withinPercentageBounds(total / count) : fallback);
this.consumer.accept(event);
}
private static int withinPercentageBounds(int value) {
if (value < 0) {
return 0;
}
return Math.min(value, 100);
}
/**
* Progress for an individual layer.
*/
private static class Layer {
private int downloadProgress;
private int extractProgress;
Layer(String id) {
}
void update(PullImageUpdateEvent event) {
if (event.getProgressDetail() != null) {
ProgressDetail detail = event.getProgressDetail();
if ("Downloading".equals(event.getStatus())) {
this.downloadProgress = updateProgress(this.downloadProgress, detail);
}
if ("Extracting".equals(event.getStatus())) {
this.extractProgress = updateProgress(this.extractProgress, detail);
}
}
}
private int updateProgress(int current, ProgressDetail detail) {
int result = withinPercentageBounds((int) ((100.0 / detail.getTotal()) * detail.getCurrent()));
return Math.max(result, current);
}
void finish() {
this.downloadProgress = 100;
this.extractProgress = 100;
}
int getProgress() {
return withinPercentageBounds((this.downloadProgress + this.extractProgress) / 2);
}
super(consumer, TRACKED_STATUS_KEYS);
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2012-2020 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;
import java.util.function.Consumer;
/**
* {@link UpdateListener} that calculates the total progress of the entire push operation
* and publishes {@link TotalProgressEvent}.
*
* @author Scott Frederick
* @since 2.4.0
*/
public class TotalProgressPushListener extends TotalProgressListener<PushImageUpdateEvent> {
private static final String[] TRACKED_STATUS_KEYS = { "Pushing" };
/**
* Create a new {@link TotalProgressPushListener} that prints a progress bar to
* {@link System#out}.
* @param prefix the prefix to output
*/
public TotalProgressPushListener(String prefix) {
this(new TotalProgressBar(prefix));
}
/**
* Create a new {@link TotalProgressPushListener} that sends {@link TotalProgressEvent
* events} to the given consumer.
* @param consumer the consumer that receives {@link TotalProgressEvent progress
* events}
*/
public TotalProgressPushListener(Consumer<TotalProgressEvent> consumer) {
super(consumer, TRACKED_STATUS_KEYS);
}
}

View File

@@ -29,40 +29,65 @@ public final class DockerConfiguration {
private final DockerHost host;
private final DockerRegistryAuthentication authentication;
private final DockerRegistryAuthentication builderAuthentication;
private final DockerRegistryAuthentication publishAuthentication;
public DockerConfiguration() {
this(null, null);
this(null, null, null);
}
private DockerConfiguration(DockerHost host, DockerRegistryAuthentication authentication) {
private DockerConfiguration(DockerHost host, DockerRegistryAuthentication builderAuthentication,
DockerRegistryAuthentication publishAuthentication) {
this.host = host;
this.authentication = authentication;
this.builderAuthentication = builderAuthentication;
this.publishAuthentication = publishAuthentication;
}
public DockerHost getHost() {
return this.host;
}
public DockerRegistryAuthentication getRegistryAuthentication() {
return this.authentication;
public DockerRegistryAuthentication getBuilderRegistryAuthentication() {
return this.builderAuthentication;
}
public DockerRegistryAuthentication getPublishRegistryAuthentication() {
return this.publishAuthentication;
}
public DockerConfiguration withHost(String address, boolean secure, String certificatePath) {
Assert.notNull(address, "Address must not be null");
return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.authentication);
return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.builderAuthentication,
this.publishAuthentication);
}
public DockerConfiguration withRegistryTokenAuthentication(String token) {
public DockerConfiguration withBuilderRegistryTokenAuthentication(String token) {
Assert.notNull(token, "Token must not be null");
return new DockerConfiguration(this.host, new DockerRegistryTokenAuthentication(token));
return new DockerConfiguration(this.host, new DockerRegistryTokenAuthentication(token),
this.publishAuthentication);
}
public DockerConfiguration withRegistryUserAuthentication(String username, String password, String url,
public DockerConfiguration withBuilderRegistryUserAuthentication(String username, String password, String url,
String email) {
Assert.notNull(username, "Username must not be null");
Assert.notNull(password, "Password must not be null");
return new DockerConfiguration(this.host, new DockerRegistryUserAuthentication(username, password, url, email));
return new DockerConfiguration(this.host, new DockerRegistryUserAuthentication(username, password, url, email),
this.publishAuthentication);
}
public DockerConfiguration withPublishRegistryTokenAuthentication(String token) {
Assert.notNull(token, "Token must not be null");
return new DockerConfiguration(this.host, this.builderAuthentication,
new DockerRegistryTokenAuthentication(token));
}
public DockerConfiguration withPublishRegistryUserAuthentication(String username, String password, String url,
String email) {
Assert.notNull(username, "Username must not be null");
Assert.notNull(password, "Password must not be null");
return new DockerConfiguration(this.host, this.builderAuthentication,
new DockerRegistryUserAuthentication(username, password, url, email));
}
}

View File

@@ -30,7 +30,7 @@ public class DockerHost {
private final String certificatePath;
protected DockerHost(String address, boolean secure, String certificatePath) {
public DockerHost(String address, boolean secure, String certificatePath) {
this.address = address;
this.secure = secure;
this.certificatePath = certificatePath;

View File

@@ -25,9 +25,9 @@ package org.springframework.boot.buildpack.platform.docker.configuration;
public interface DockerRegistryAuthentication {
/**
* Create the auth header that should be used for docker authentication.
* Returns the auth header that should be used for docker authentication.
* @return the auth header
*/
String createAuthHeader();
String getAuthHeader();
}

View File

@@ -30,6 +30,7 @@ class DockerRegistryTokenAuthentication extends JsonEncodedDockerRegistryAuthent
DockerRegistryTokenAuthentication(String token) {
this.token = token;
createAuthHeader();
}
String getToken() {

View File

@@ -42,6 +42,7 @@ class DockerRegistryUserAuthentication extends JsonEncodedDockerRegistryAuthenti
this.password = password;
this.url = url;
this.email = email;
createAuthHeader();
}
String getUsername() {

View File

@@ -22,17 +22,23 @@ import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
import org.springframework.util.Base64Utils;
/**
* {@link DockerRegistryAuthentication} that uses creates a Base64 encoded auth header
* value based on the JSON created from the instance.
* {@link DockerRegistryAuthentication} that uses a Base64 encoded auth header value based
* on the JSON created from the instance.
*
* @author Scott Frederick
*/
class JsonEncodedDockerRegistryAuthentication implements DockerRegistryAuthentication {
private String authHeader;
@Override
public String createAuthHeader() {
public String getAuthHeader() {
return this.authHeader;
}
protected void createAuthHeader() {
try {
return Base64Utils.encodeToUrlSafeString(SharedObjectMapper.get().writeValueAsBytes(this));
this.authHeader = Base64Utils.encodeToUrlSafeString(SharedObjectMapper.get().writeValueAsBytes(this));
}
catch (JsonProcessingException ex) {
throw new IllegalStateException("Error creating Docker registry authentication header", ex);

View File

@@ -36,7 +36,6 @@ import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.AbstractHttpEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.boot.buildpack.platform.io.Content;
import org.springframework.boot.buildpack.platform.io.IOConsumer;
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
@@ -53,19 +52,17 @@ import org.springframework.util.StringUtils;
*/
abstract class HttpClientTransport implements HttpTransport {
static final String REGISTRY_AUTH_HEADER = "X-Registry-Auth";
private final CloseableHttpClient client;
private final HttpHost host;
private final String registryAuthHeader;
protected HttpClientTransport(CloseableHttpClient client, HttpHost host,
DockerRegistryAuthentication authentication) {
protected HttpClientTransport(CloseableHttpClient client, HttpHost host) {
Assert.notNull(client, "Client must not be null");
Assert.notNull(host, "Host must not be null");
this.client = client;
this.host = host;
this.registryAuthHeader = buildRegistryAuthHeader(authentication);
}
/**
@@ -88,6 +85,17 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(new HttpPost(uri));
}
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
* @param registryAuth registry authentication credentials
* @return the operation response
*/
@Override
public Response post(URI uri, String registryAuth) {
return execute(new HttpPost(uri), registryAuth);
}
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
@@ -122,11 +130,6 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(new HttpDelete(uri));
}
private String buildRegistryAuthHeader(DockerRegistryAuthentication authentication) {
String authHeader = (authentication != null) ? authentication.createAuthHeader() : null;
return (StringUtils.hasText(authHeader)) ? authHeader : null;
}
private Response execute(HttpEntityEnclosingRequestBase request, String contentType,
IOConsumer<OutputStream> writer) {
request.setHeader(HttpHeaders.CONTENT_TYPE, contentType);
@@ -134,11 +137,15 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(request);
}
private Response execute(HttpEntityEnclosingRequestBase request, String registryAuth) {
if (StringUtils.hasText(registryAuth)) {
request.setHeader(REGISTRY_AUTH_HEADER, registryAuth);
}
return execute(request);
}
private Response execute(HttpUriRequest request) {
try {
if (this.registryAuthHeader != null) {
request.addHeader("X-Registry-Auth", this.registryAuthHeader);
}
CloseableHttpResponse response = this.client.execute(this.host, request);
StatusLine statusLine = response.getStatusLine();
int statusCode = statusLine.getStatusCode();

View File

@@ -23,6 +23,7 @@ import java.io.OutputStream;
import java.net.URI;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
import org.springframework.boot.buildpack.platform.io.IOConsumer;
import org.springframework.boot.buildpack.platform.system.Environment;
@@ -51,6 +52,15 @@ public interface HttpTransport {
*/
Response post(URI uri) throws IOException;
/**
* Perform a HTTP POST operation.
* @param uri the destination URI (excluding any host/port)
* @param registryAuth registry authentication credentials
* @return the operation response
* @throws IOException on IO error
*/
Response post(URI uri, String registryAuth) throws IOException;
/**
* Perform a HTTP POST operation.
* @param uri the destination URI (excluding any host/port)
@@ -85,17 +95,17 @@ public interface HttpTransport {
* @return a {@link HttpTransport} instance
*/
static HttpTransport create() {
return create(new DockerConfiguration());
return create(Environment.SYSTEM);
}
/**
* Create the most suitable {@link HttpTransport} based on the
* {@link Environment#SYSTEM system environment}.
* @param dockerConfiguration the Docker engine configuration
* @param dockerHost the Docker engine host configuration
* @return a {@link HttpTransport} instance
*/
static HttpTransport create(DockerConfiguration dockerConfiguration) {
return create(Environment.SYSTEM, dockerConfiguration);
static HttpTransport create(DockerHost dockerHost) {
return create(Environment.SYSTEM, dockerHost);
}
/**
@@ -105,19 +115,19 @@ public interface HttpTransport {
* @return a {@link HttpTransport} instance
*/
static HttpTransport create(Environment environment) {
return create(environment, new DockerConfiguration());
return create(environment, null);
}
/**
* Create the most suitable {@link HttpTransport} based on the given
* {@link Environment} and {@link DockerConfiguration}.
* @param environment the source environment
* @param dockerConfiguration the Docker engine configuration
* @param dockerHost the Docker engine host configuration
* @return a {@link HttpTransport} instance
*/
static HttpTransport create(Environment environment, DockerConfiguration dockerConfiguration) {
HttpTransport remote = RemoteHttpClientTransport.createIfPossible(environment, dockerConfiguration);
return (remote != null) ? remote : LocalHttpClientTransport.create(environment, dockerConfiguration);
static HttpTransport create(Environment environment, DockerHost dockerHost) {
HttpTransport remote = RemoteHttpClientTransport.createIfPossible(environment, dockerHost);
return (remote != null) ? remote : LocalHttpClientTransport.create(environment);
}
/**

View File

@@ -38,8 +38,6 @@ import org.apache.http.impl.conn.BasicHttpClientConnectionManager;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.Args;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.boot.buildpack.platform.socket.DomainSocket;
import org.springframework.boot.buildpack.platform.socket.NamedPipeSocket;
import org.springframework.boot.buildpack.platform.system.Environment;
@@ -58,16 +56,15 @@ final class LocalHttpClientTransport extends HttpClientTransport {
private static final HttpHost LOCAL_DOCKER_HOST = HttpHost.create("docker://localhost");
private LocalHttpClientTransport(CloseableHttpClient client, DockerRegistryAuthentication authentication) {
super(client, LOCAL_DOCKER_HOST, authentication);
private LocalHttpClientTransport(CloseableHttpClient client) {
super(client, LOCAL_DOCKER_HOST);
}
static LocalHttpClientTransport create(Environment environment, DockerConfiguration dockerConfiguration) {
static LocalHttpClientTransport create(Environment environment) {
HttpClientBuilder builder = HttpClients.custom();
builder.setConnectionManager(new LocalConnectionManager(socketFilePath(environment)));
builder.setSchemePortResolver(new LocalSchemePortResolver());
return new LocalHttpClientTransport(builder.build(),
(dockerConfiguration != null) ? dockerConfiguration.getRegistryAuthentication() : null);
return new LocalHttpClientTransport(builder.build());
}
private static String socketFilePath(Environment environment) {

View File

@@ -28,9 +28,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory;
import org.springframework.boot.buildpack.platform.system.Environment;
import org.springframework.util.Assert;
@@ -51,23 +49,21 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
private static final String DOCKER_CERT_PATH = "DOCKER_CERT_PATH";
private RemoteHttpClientTransport(CloseableHttpClient client, HttpHost host,
DockerRegistryAuthentication authentication) {
super(client, host, authentication);
private RemoteHttpClientTransport(CloseableHttpClient client, HttpHost host) {
super(client, host);
}
static RemoteHttpClientTransport createIfPossible(Environment environment,
DockerConfiguration dockerConfiguration) {
return createIfPossible(environment, dockerConfiguration, new SslContextFactory());
static RemoteHttpClientTransport createIfPossible(Environment environment, DockerHost dockerHost) {
return createIfPossible(environment, dockerHost, new SslContextFactory());
}
static RemoteHttpClientTransport createIfPossible(Environment environment, DockerConfiguration dockerConfiguration,
static RemoteHttpClientTransport createIfPossible(Environment environment, DockerHost dockerHost,
SslContextFactory sslContextFactory) {
DockerHost host = getHost(environment, dockerConfiguration);
DockerHost host = getHost(environment, dockerHost);
if (host == null || host.getAddress() == null || isLocalFileReference(host.getAddress())) {
return null;
}
return create(host, dockerConfiguration, sslContextFactory, HttpHost.create(host.getAddress()));
return create(host, sslContextFactory, HttpHost.create(host.getAddress()));
}
private static boolean isLocalFileReference(String host) {
@@ -80,16 +76,15 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
}
}
private static RemoteHttpClientTransport create(DockerHost host, DockerConfiguration dockerConfiguration,
SslContextFactory sslContextFactory, HttpHost tcpHost) {
private static RemoteHttpClientTransport create(DockerHost host, SslContextFactory sslContextFactory,
HttpHost tcpHost) {
HttpClientBuilder builder = HttpClients.custom();
if (host.isSecure()) {
builder.setSSLSocketFactory(getSecureConnectionSocketFactory(host, sslContextFactory));
}
String scheme = host.isSecure() ? "https" : "http";
HttpHost httpHost = new HttpHost(tcpHost.getHostName(), tcpHost.getPort(), scheme);
return new RemoteHttpClientTransport(builder.build(), httpHost,
(dockerConfiguration != null) ? dockerConfiguration.getRegistryAuthentication() : null);
return new RemoteHttpClientTransport(builder.build(), httpHost);
}
private static LayeredConnectionSocketFactory getSecureConnectionSocketFactory(DockerHost host,
@@ -101,14 +96,11 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
return new SSLConnectionSocketFactory(sslContext);
}
private static DockerHost getHost(Environment environment, DockerConfiguration dockerConfiguration) {
private static DockerHost getHost(Environment environment, DockerHost dockerHost) {
if (environment.get(DOCKER_HOST) != null) {
return new EnvironmentDockerHost(environment);
}
if (dockerConfiguration != null && dockerConfiguration.getHost() != null) {
return dockerConfiguration.getHost();
}
return null;
return dockerHost;
}
private static class EnvironmentDockerHost extends DockerHost {

View File

@@ -30,6 +30,7 @@ import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi
import org.springframework.boot.buildpack.platform.docker.DockerApi.ImageApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
import org.springframework.boot.buildpack.platform.docker.TotalProgressPullListener;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.transport.DockerEngineException;
import org.springframework.boot.buildpack.platform.docker.type.ContainerReference;
import org.springframework.boot.buildpack.platform.docker.type.ContainerStatus;
@@ -44,11 +45,13 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Tests for {@link Builder}.
@@ -83,18 +86,53 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
builder.build(request);
assertThat(out.toString()).contains("Running creator");
assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull());
verify(docker.image()).pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull());
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verifyNoMoreInteractions(docker.image());
}
@Test
void buildInvokesBuilderAndPublishesImage() throws Exception {
TestPrintStream out = new TestPrintStream();
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
DockerConfiguration dockerConfiguration = new DockerConfiguration()
.withBuilderRegistryTokenAuthentication("builder token")
.withPublishRegistryTokenAuthentication("publish token");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker, dockerConfiguration);
BuildRequest request = getTestRequest().withPublish(true);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()));
verify(docker.image()).pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()));
verify(docker.image()).push(eq(request.getName()), any(),
eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()));
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verifyNoMoreInteractions(docker.image());
}
@Test
@@ -103,11 +141,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image-with-no-run-image-tag.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of("gcr.io/paketo-buildpacks/builder:latest")), any()))
given(docker.image().pull(eq(ImageReference.of("gcr.io/paketo-buildpacks/builder:latest")), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:latest")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:latest")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withBuilder(ImageReference.of("gcr.io/paketo-buildpacks/builder"));
builder.build(request);
assertThat(out.toString()).contains("Running creator");
@@ -123,12 +161,12 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image-with-run-image-digest.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of(
"docker.io/cloudfoundry/run@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
any())).willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
any(), isNull())).willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
builder.build(request);
assertThat(out.toString()).contains("Running creator");
@@ -144,11 +182,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("example.com/custom/run:latest")), any()))
given(docker.image().pull(eq(ImageReference.of("example.com/custom/run:latest")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withRunImage(ImageReference.of("example.com/custom/run:latest"));
builder.build(request);
assertThat(out.toString()).contains("Running creator");
@@ -164,15 +202,15 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME))))
.willReturn(builderImage);
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))
.willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.NEVER);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
@@ -190,15 +228,15 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME))))
.willReturn(builderImage);
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))
.willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.ALWAYS);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
@@ -206,7 +244,7 @@ class BuilderTests {
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verify(docker.image(), times(2)).pull(any(), any());
verify(docker.image(), times(2)).pull(any(), any(), isNull());
verify(docker.image(), never()).inspect(any());
}
@@ -216,9 +254,9 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)))).willThrow(
new DockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, null))
@@ -226,7 +264,7 @@ class BuilderTests {
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")))).willThrow(
new DockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, null))
.willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.IF_NOT_PRESENT);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
@@ -235,7 +273,7 @@ class BuilderTests {
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verify(docker.image(), times(2)).inspect(any());
verify(docker.image(), times(2)).pull(any(), any());
verify(docker.image(), times(2)).pull(any(), any(), isNull());
}
@Test
@@ -244,11 +282,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image-with-bad-stack.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
assertThatIllegalStateException().isThrownBy(() -> builder.build(request)).withMessage(
"Run image stack 'org.cloudfoundry.stacks.cfwindowsfs3' does not match builder stack 'io.buildpacks.stacks.bionic'");
@@ -260,11 +298,11 @@ class BuilderTests {
DockerApi docker = mockDockerApiLifecycleError();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
assertThatExceptionOfType(BuilderException.class).isThrownBy(() -> builder.build(request))
.withMessage("Builder lifecycle 'creator' failed with status code 9");

View File

@@ -54,6 +54,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
@@ -127,6 +128,9 @@ class DockerApiTests {
@Mock
private UpdateListener<PullImageUpdateEvent> pullListener;
@Mock
private UpdateListener<PushImageUpdateEvent> pushListener;
@Mock
private UpdateListener<LoadImageUpdateEvent> loadListener;
@@ -156,7 +160,7 @@ class DockerApiTests {
URI createUri = new URI(IMAGES_URL + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase");
String imageHash = "4acb6bfd6c4f0cabaf7f3690e444afe51f1c7de54d51da7e63fac709c56f1c30";
URI imageUri = new URI(IMAGES_URL + "/gcr.io/paketo-buildpacks/builder@sha256:" + imageHash + "/json");
given(http().post(createUri)).willReturn(responseOf("pull-stream.json"));
given(http().post(eq(createUri), isNull())).willReturn(responseOf("pull-stream.json"));
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.pull(reference, this.pullListener);
assertThat(image.getLayers()).hasSize(46);
@@ -166,6 +170,57 @@ class DockerApiTests {
ordered.verify(this.pullListener).onFinish();
}
@Test
void pullWithRegistryAuthPullsImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base");
URI createUri = new URI(IMAGES_URL + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase");
String imageHash = "4acb6bfd6c4f0cabaf7f3690e444afe51f1c7de54d51da7e63fac709c56f1c30";
URI imageUri = new URI(IMAGES_URL + "/gcr.io/paketo-buildpacks/builder@sha256:" + imageHash + "/json");
given(http().post(eq(createUri), eq("auth token"))).willReturn(responseOf("pull-stream.json"));
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.pull(reference, this.pullListener, "auth token");
assertThat(image.getLayers()).hasSize(46);
InOrder ordered = inOrder(this.pullListener);
ordered.verify(this.pullListener).onStart();
ordered.verify(this.pullListener, times(595)).onUpdate(any());
ordered.verify(this.pullListener).onFinish();
}
@Test
void pushWhenReferenceIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.push(null, this.pushListener, null))
.withMessage("Reference must not be null");
}
@Test
void pushWhenListenerIsNullThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.api.push(ImageReference.of("ubuntu"), null, null))
.withMessage("Listener must not be null");
}
@Test
void pushPushesImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("localhost:5000/ubuntu");
URI pushUri = new URI(IMAGES_URL + "/localhost:5000/ubuntu/push");
given(http().post(pushUri, "auth token")).willReturn(responseOf("push-stream.json"));
this.api.push(reference, this.pushListener, "auth token");
InOrder ordered = inOrder(this.pushListener);
ordered.verify(this.pushListener).onStart();
ordered.verify(this.pushListener, times(44)).onUpdate(any());
ordered.verify(this.pushListener).onFinish();
}
@Test
void pushWithErrorInStreamThrowsException() throws Exception {
ImageReference reference = ImageReference.of("localhost:5000/ubuntu");
URI pushUri = new URI(IMAGES_URL + "/localhost:5000/ubuntu/push");
given(http().post(pushUri, "auth token")).willReturn(responseOf("push-stream-with-error.json"));
assertThatIllegalStateException()
.isThrownBy(() -> this.api.push(reference, this.pushListener, "auth token"))
.withMessageContaining("test message");
}
@Test
void loadWhenArchiveIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.load(null, UpdateListener.none()))

View File

@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link LoadImageUpdateEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class LoadImageUpdateEventTests extends ProgressUpdateEventTests {
class LoadImageUpdateEventTests extends ProgressUpdateEventTests<LoadImageUpdateEvent> {
@Test
void getStreamReturnsStream() {
LoadImageUpdateEvent event = (LoadImageUpdateEvent) createEvent();
LoadImageUpdateEvent event = createEvent();
assertThat(event.getStream()).isEqualTo("stream");
}
@Override
protected ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
protected LoadImageUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
return new LoadImageUpdateEvent("stream", status, progressDetail, progress);
}

View File

@@ -26,8 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link ProgressUpdateEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
abstract class ProgressUpdateEventTests {
abstract class ProgressUpdateEventTests<E extends ProgressUpdateEvent> {
@Test
void getStatusReturnsStatus() {
@@ -66,10 +67,10 @@ abstract class ProgressUpdateEventTests {
assertThat(ProgressDetail.isEmpty(detail)).isFalse();
}
protected ProgressUpdateEvent createEvent() {
protected E createEvent() {
return createEvent("status", new ProgressDetail(1, 2), "progress");
}
protected abstract ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress);
protected abstract E createEvent(String status, ProgressDetail progressDetail, String progress);
}

View File

@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link PullImageUpdateEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class PullImageUpdateEventTests extends ProgressUpdateEventTests {
class PullImageUpdateEventTests extends ProgressUpdateEventTests<PullImageUpdateEvent> {
@Test
void getIdReturnsId() {
PullImageUpdateEvent event = (PullImageUpdateEvent) createEvent();
PullImageUpdateEvent event = createEvent();
assertThat(event.getId()).isEqualTo("id");
}
@Override
protected ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
protected PullImageUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
return new PullImageUpdateEvent("id", status, progressDetail, progress);
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2012-2020 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;
import org.junit.jupiter.api.Test;
import org.springframework.boot.buildpack.platform.docker.ProgressUpdateEvent.ProgressDetail;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link PushImageUpdateEvent}.
*
* @author Scott Frederick
*/
class PushImageUpdateEventTests extends ProgressUpdateEventTests<PushImageUpdateEvent> {
@Test
void getIdReturnsId() {
PushImageUpdateEvent event = createEvent();
assertThat(event.getId()).isEqualTo("id");
}
@Test
void getErrorReturnsErrorDetail() {
PushImageUpdateEvent event = new PushImageUpdateEvent(null, null, null, null,
new PushImageUpdateEvent.ErrorDetail("test message"));
assertThat(event.getErrorDetail().getMessage()).isEqualTo("test message");
}
@Override
protected PushImageUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
return new PushImageUpdateEvent("id", status, progressDetail, progress, null);
}
}

View File

@@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import com.fasterxml.jackson.annotation.JsonCreator;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@@ -33,13 +34,14 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link TotalProgressPullListener}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class TotalProgressPullListenerTests extends AbstractJsonTests {
class TotalProgressListenerTests extends AbstractJsonTests {
@Test
void totalProgress() throws Exception {
List<Integer> progress = new ArrayList<>();
TotalProgressPullListener listener = new TotalProgressPullListener((event) -> progress.add(event.getPercent()));
TestTotalProgressListener listener = new TestTotalProgressListener((event) -> progress.add(event.getPercent()));
run(listener);
int last = 0;
for (Integer update : progress) {
@@ -52,26 +54,25 @@ class TotalProgressPullListenerTests extends AbstractJsonTests {
@Test
@Disabled("For visual inspection")
void totalProgressUpdatesSmoothly() throws Exception {
TestTotalProgressPullListener listener = new TestTotalProgressPullListener(
new TotalProgressBar("Pulling layers:"));
TestTotalProgressListener listener = new TestTotalProgressListener(new TotalProgressBar("Pulling layers:"));
run(listener);
}
private void run(TotalProgressPullListener listener) throws IOException {
private void run(TestTotalProgressListener listener) throws IOException {
JsonStream jsonStream = new JsonStream(getObjectMapper());
listener.onStart();
jsonStream.get(getContent("pull-stream.json"), PullImageUpdateEvent.class, listener::onUpdate);
jsonStream.get(getContent("pull-stream.json"), TestImageUpdateEvent.class, listener::onUpdate);
listener.onFinish();
}
private static class TestTotalProgressPullListener extends TotalProgressPullListener {
private static class TestTotalProgressListener extends TotalProgressListener<TestImageUpdateEvent> {
TestTotalProgressPullListener(Consumer<TotalProgressEvent> consumer) {
super(consumer);
TestTotalProgressListener(Consumer<TotalProgressEvent> consumer) {
super(consumer, new String[] { "Pulling", "Downloading", "Extracting" });
}
@Override
public void onUpdate(PullImageUpdateEvent event) {
public void onUpdate(TestImageUpdateEvent event) {
super.onUpdate(event);
try {
Thread.sleep(10);
@@ -82,4 +83,13 @@ class TotalProgressPullListenerTests extends AbstractJsonTests {
}
private static class TestImageUpdateEvent extends ImageProgressUpdateEvent {
@JsonCreator
TestImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) {
super(id, status, progressDetail, progress);
}
}
}

View File

@@ -31,14 +31,14 @@ public class DockerConfigurationTests {
@Test
void createDockerConfigurationWithDefaults() {
DockerConfiguration configuration = new DockerConfiguration();
assertThat(configuration.getRegistryAuthentication()).isNull();
assertThat(configuration.getBuilderRegistryAuthentication()).isNull();
}
@Test
void createDockerConfigurationWithUserAuth() {
DockerConfiguration configuration = new DockerConfiguration().withRegistryUserAuthentication("user", "secret",
"https://docker.example.com", "docker@example.com");
DockerRegistryAuthentication auth = configuration.getRegistryAuthentication();
DockerConfiguration configuration = new DockerConfiguration().withBuilderRegistryUserAuthentication("user",
"secret", "https://docker.example.com", "docker@example.com");
DockerRegistryAuthentication auth = configuration.getBuilderRegistryAuthentication();
assertThat(auth).isNotNull();
assertThat(auth).isInstanceOf(DockerRegistryUserAuthentication.class);
DockerRegistryUserAuthentication userAuth = (DockerRegistryUserAuthentication) auth;
@@ -50,8 +50,8 @@ public class DockerConfigurationTests {
@Test
void createDockerConfigurationWithTokenAuth() {
DockerConfiguration configuration = new DockerConfiguration().withRegistryTokenAuthentication("token");
DockerRegistryAuthentication auth = configuration.getRegistryAuthentication();
DockerConfiguration configuration = new DockerConfiguration().withBuilderRegistryTokenAuthentication("token");
DockerRegistryAuthentication auth = configuration.getBuilderRegistryAuthentication();
assertThat(auth).isNotNull();
assertThat(auth).isInstanceOf(DockerRegistryTokenAuthentication.class);
DockerRegistryTokenAuthentication tokenAuth = (DockerRegistryTokenAuthentication) auth;

View File

@@ -29,13 +29,15 @@ import org.springframework.util.StreamUtils;
/**
* Tests for {@link DockerRegistryTokenAuthentication}.
*
* @author Scott Frederick
*/
class DockerRegistryTokenAuthenticationTests extends AbstractJsonTests {
@Test
void createAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryTokenAuthentication auth = new DockerRegistryTokenAuthentication("tokenvalue");
String header = auth.createAuthHeader();
String header = auth.getAuthHeader();
String expectedJson = StreamUtils.copyToString(getContent("auth-token.json"), StandardCharsets.UTF_8);
JSONAssert.assertEquals(expectedJson, new String(Base64Utils.decodeFromUrlSafeString(header)), false);
}

View File

@@ -29,6 +29,8 @@ import org.springframework.util.StreamUtils;
/**
* Tests for {@link DockerRegistryUserAuthentication}.
*
* @author Scott Frederick
*/
class DockerRegistryUserAuthenticationTests extends AbstractJsonTests {
@@ -36,13 +38,13 @@ class DockerRegistryUserAuthenticationTests extends AbstractJsonTests {
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.createAuthHeader()), false);
JSONAssert.assertEquals(jsonContent("auth-user-full.json"), decoded(auth.getAuthHeader()), false);
}
@Test
void createFullAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret", null, null);
JSONAssert.assertEquals(jsonContent("auth-user-minimal.json"), decoded(auth.createAuthHeader()), false);
JSONAssert.assertEquals(jsonContent("auth-user-minimal.json"), decoded(auth.getAuthHeader()), false);
}
private String jsonContent(String s) throws IOException {

View File

@@ -22,7 +22,6 @@ import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHeaders;
@@ -44,9 +43,7 @@ import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.transport.HttpTransport.Response;
import org.springframework.util.Base64Utils;
import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -123,6 +120,37 @@ class HttpClientTransportTests {
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(request.getFirstHeader(HttpClientTransport.REGISTRY_AUTH_HEADER)).isNull();
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void postWithRegistryAuthShouldExecuteHttpPostWithHeader() throws Exception {
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.post(this.uri, "auth token");
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(request.getFirstHeader(HttpClientTransport.REGISTRY_AUTH_HEADER).getValue()).isEqualTo("auth token");
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void postWithEmptyRegistryAuthShouldExecuteHttpPostWithoutHeader() throws Exception {
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.post(this.uri, "");
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(request.getFirstHeader(HttpClientTransport.REGISTRY_AUTH_HEADER)).isNull();
assertThat(response.getContent()).isSameAs(this.content);
}
@@ -237,47 +265,6 @@ class HttpClientTransportTests {
.satisfies((ex) -> assertThat(ex.getMessage()).contains("test IO exception"));
}
@Test
void getWithDockerRegistryUserAuthWillSendAuthHeader() throws IOException {
DockerConfiguration dockerConfiguration = new DockerConfiguration().withRegistryUserAuthentication("user",
"secret", "https://docker.example.com", "docker@example.com");
this.http = new TestHttpClientTransport(this.client, dockerConfiguration);
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.get(this.uri);
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpGet.class);
assertThat(request.getURI()).isEqualTo(this.uri);
Header[] registryAuthHeaders = request.getHeaders("X-Registry-Auth");
assertThat(registryAuthHeaders).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthHeaders[0].getValue())))
.contains("\"username\" : \"user\"").contains("\"password\" : \"secret\"")
.contains("\"email\" : \"docker@example.com\"")
.contains("\"serveraddress\" : \"https://docker.example.com\"");
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void getWithDockerRegistryTokenAuthWillSendAuthHeader() throws IOException {
DockerConfiguration dockerConfiguration = new DockerConfiguration().withRegistryTokenAuthentication("token");
this.http = new TestHttpClientTransport(this.client, dockerConfiguration);
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.get(this.uri);
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpGet.class);
assertThat(request.getURI()).isEqualTo(this.uri);
Header[] registryAuthHeaders = request.getHeaders("X-Registry-Auth");
assertThat(registryAuthHeaders).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthHeaders[0].getValue())))
.contains("\"identitytoken\" : \"token\"");
assertThat(response.getContent()).isSameAs(this.content);
}
private String writeToString(HttpEntity entity) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
entity.writeTo(out);
@@ -296,11 +283,7 @@ class HttpClientTransportTests {
static class TestHttpClientTransport extends HttpClientTransport {
protected TestHttpClientTransport(CloseableHttpClient client) {
super(client, HttpHost.create("docker://localhost"), null);
}
protected TestHttpClientTransport(CloseableHttpClient client, DockerConfiguration dockerConfiguration) {
super(client, HttpHost.create("docker://localhost"), dockerConfiguration.getRegistryAuthentication());
super(client, HttpHost.create("docker://localhost"));
}
}

View File

@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory;
import static org.assertj.core.api.Assertions.assertThat;
@@ -52,7 +53,7 @@ class RemoteHttpClientTransportTests {
@Test
void createIfPossibleWhenDockerHostIsNotSetReturnsNull() {
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
new DockerHost(null, false, null));
assertThat(transport).isNull();
}
@@ -67,8 +68,7 @@ class RemoteHttpClientTransportTests {
String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath()
.toString();
this.environment.put("DOCKER_HOST", dummySocketFilePath);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
assertThat(transport).isNull();
}
@@ -77,22 +77,21 @@ class RemoteHttpClientTransportTests {
String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath()
.toString();
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost(dummySocketFilePath, false, null));
new DockerHost(dummySocketFilePath, false, null));
assertThat(transport).isNull();
}
@Test
void createIfPossibleWhenDockerHostInEnvironmentIsAddressReturnsTransport() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
assertThat(transport).isNotNull();
}
@Test
void createIfPossibleWhenDockerHostInConfigurationIsAddressReturnsTransport() {
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", false, null));
new DockerHost("tcp://192.168.1.2:2376", false, null));
assertThat(transport).isNotNull();
}
@@ -100,8 +99,8 @@ class RemoteHttpClientTransportTests {
void createIfPossibleWhenTlsVerifyInEnvironmentWithMissingCertPathThrowsException() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
this.environment.put("DOCKER_TLS_VERIFY", "1");
assertThatIllegalArgumentException().isThrownBy(
() -> RemoteHttpClientTransport.createIfPossible(this.environment::get, this.dockerConfiguration))
assertThatIllegalArgumentException()
.isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(this.environment::get, null))
.withMessageContaining("Docker host TLS verification requires trust material");
}
@@ -109,15 +108,14 @@ class RemoteHttpClientTransportTests {
void createIfPossibleWhenTlsVerifyInConfigurationWithMissingCertPathThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", true, null)))
new DockerHost("tcp://192.168.1.2:2376", true, null)))
.withMessageContaining("Docker host TLS verification requires trust material");
}
@Test
void createIfPossibleWhenNoTlsVerifyUsesHttp() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
assertThat(transport.getHost()).satisfies(hostOf("http", "192.168.1.2", 2376));
}
@@ -129,7 +127,7 @@ class RemoteHttpClientTransportTests {
SslContextFactory sslContextFactory = mock(SslContextFactory.class);
given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault());
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration, sslContextFactory);
this.dockerConfiguration.getHost(), sslContextFactory);
assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376));
}
@@ -138,20 +136,11 @@ class RemoteHttpClientTransportTests {
SslContextFactory sslContextFactory = mock(SslContextFactory.class);
given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault());
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", true, "/test-cert-path"),
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", true, "/test-cert-path").getHost(),
sslContextFactory);
assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376));
}
@Test
void createIfPossibleWithDockerConfigurationUserAuthReturnsTransport() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
new DockerConfiguration().withRegistryUserAuthentication("user", "secret", "http://docker.example.com",
"docker@example.com"));
assertThat(transport).isNotNull();
}
private Consumer<HttpHost> hostOf(String scheme, String hostName, int port) {
return (host) -> {
assertThat(host).isNotNull();

View File

@@ -0,0 +1,7 @@
{
"status":"The push refers to repository [localhost:5000/ubuntu]"
}
{"status":"Preparing","progressDetail":{},"id":"782f5f011dda"}
{"status":"Preparing","progressDetail":{},"id":"90ac32a0d9ab"}
{"status":"Preparing","progressDetail":{},"id":"d42a4fdf4b2a"}
{"errorDetail":{"message":"test message"},"error":"test error"}

View File

@@ -0,0 +1,46 @@
{
"status":"The push refers to repository [localhost:5000/ubuntu]"
}
{"status":"Preparing","progressDetail":{},"id":"782f5f011dda"}
{"status":"Preparing","progressDetail":{},"id":"90ac32a0d9ab"}
{"status":"Preparing","progressDetail":{},"id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":512,"total":7},"progress":"[==================================================\u003e] 512B","id":"782f5f011dda"}
{"status":"Pushing","progressDetail":{"current":512,"total":811},"progress":"[===============================\u003e ] 512B/811B","id":"90ac32a0d9ab"}
{"status":"Pushing","progressDetail":{"current":3072,"total":7},"progress":"[==================================================\u003e] 3.072kB","id":"782f5f011dda"}
{"status":"Pushing","progressDetail":{"current":15360,"total":811},"progress":"[==================================================\u003e] 15.36kB","id":"90ac32a0d9ab"}
{"status":"Pushing","progressDetail":{"current":543232,"total":72874905},"progress":"[\u003e ] 543.2kB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushed","progressDetail":{},"id":"90ac32a0d9ab"}
{"status":"Pushed","progressDetail":{},"id":"782f5f011dda"}
{"status":"Pushing","progressDetail":{"current":2713600,"total":72874905},"progress":"[=\u003e ] 2.714MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":4870656,"total":72874905},"progress":"[===\u003e ] 4.871MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":7069184,"total":72874905},"progress":"[====\u003e ] 7.069MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":9238528,"total":72874905},"progress":"[======\u003e ] 9.239MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":11354112,"total":72874905},"progress":"[=======\u003e ] 11.35MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":13582336,"total":72874905},"progress":"[=========\u003e ] 13.58MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":16336248,"total":72874905},"progress":"[===========\u003e ] 16.34MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":19036160,"total":72874905},"progress":"[=============\u003e ] 19.04MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":21762560,"total":72874905},"progress":"[==============\u003e ] 21.76MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":24480256,"total":72874905},"progress":"[================\u003e ] 24.48MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":28756480,"total":72874905},"progress":"[===================\u003e ] 28.76MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":32001024,"total":72874905},"progress":"[=====================\u003e ] 32MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":34195456,"total":72874905},"progress":"[=======================\u003e ] 34.2MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":36393984,"total":72874905},"progress":"[========================\u003e ] 36.39MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":38587904,"total":72874905},"progress":"[==========================\u003e ] 38.59MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":41290752,"total":72874905},"progress":"[============================\u003e ] 41.29MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":43487744,"total":72874905},"progress":"[=============================\u003e ] 43.49MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":45683200,"total":72874905},"progress":"[===============================\u003e ] 45.68MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":48413184,"total":72874905},"progress":"[=================================\u003e ] 48.41MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":51119104,"total":72874905},"progress":"[===================================\u003e ] 51.12MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":53327360,"total":72874905},"progress":"[====================================\u003e ] 53.33MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":54964224,"total":72874905},"progress":"[=====================================\u003e ] 54.96MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":57169408,"total":72874905},"progress":"[=======================================\u003e ] 57.17MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":59355825,"total":72874905},"progress":"[========================================\u003e ] 59.36MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":62002592,"total":72874905},"progress":"[==========================================\u003e ] 62MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":64700928,"total":72874905},"progress":"[============================================\u003e ] 64.7MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":67435688,"total":72874905},"progress":"[==============================================\u003e ] 67.44MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":70095743,"total":72874905},"progress":"[================================================\u003e ] 70.1MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":72823808,"total":72874905},"progress":"[=================================================\u003e ] 72.82MB/72.87MB","id":"d42a4fdf4b2a"}
{"status":"Pushing","progressDetail":{"current":75247104,"total":72874905},"progress":"[==================================================\u003e] 75.25MB","id":"d42a4fdf4b2a"}
{"status":"Pushed","progressDetail":{},"id":"d42a4fdf4b2a"}
{"status":"latest: digest: sha256:2e70e9c81838224b5311970dbf7ed16802fbfe19e7a70b3cbfa3d7522aa285b4 size: 943"}
{"progressDetail":{},"aux":{"Tag":"latest","Digest":"sha256:2e70e9c81838224b5311970dbf7ed16802fbfe19e7a70b3cbfa3d7522aa285b4","Size":943}}