Commit 09b627d2 authored by Scott Frederick's avatar Scott Frederick

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
parent 8b740c07
...@@ -75,6 +75,16 @@ public abstract class AbstractBuildLog implements BuildLog { ...@@ -75,6 +75,16 @@ public abstract class AbstractBuildLog implements BuildLog {
log(String.format(" > Pulled %s '%s'", imageType.getDescription(), getDigest(image))); 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 @Override
public void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume) { public void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume) {
log(" > Executing lifecycle version " + version); log(" > Executing lifecycle version " + version);
......
...@@ -92,11 +92,24 @@ public interface BuildLog { ...@@ -92,11 +92,24 @@ public interface BuildLog {
/** /**
* Log that an image has been pulled. * 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 * @param imageType the image type that was pulled
*/ */
void pulledImage(Image image, ImageType imageType); 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. * Log that the lifecycle is executing.
* @param request the build request * @param request the build request
......
...@@ -59,6 +59,8 @@ public class BuildRequest { ...@@ -59,6 +59,8 @@ public class BuildRequest {
private final PullPolicy pullPolicy; private final PullPolicy pullPolicy;
private final boolean publish;
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) { BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) {
Assert.notNull(name, "Name must not be null"); Assert.notNull(name, "Name must not be null");
Assert.notNull(applicationContent, "ApplicationContent must not be null"); Assert.notNull(applicationContent, "ApplicationContent must not be null");
...@@ -70,12 +72,13 @@ public class BuildRequest { ...@@ -70,12 +72,13 @@ public class BuildRequest {
this.cleanCache = false; this.cleanCache = false;
this.verboseLogging = false; this.verboseLogging = false;
this.pullPolicy = PullPolicy.ALWAYS; this.pullPolicy = PullPolicy.ALWAYS;
this.publish = false;
this.creator = Creator.withVersion(""); this.creator = Creator.withVersion("");
} }
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder, BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder,
ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache, ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache,
boolean verboseLogging, PullPolicy pullPolicy) { boolean verboseLogging, PullPolicy pullPolicy, boolean publish) {
this.name = name; this.name = name;
this.applicationContent = applicationContent; this.applicationContent = applicationContent;
this.builder = builder; this.builder = builder;
...@@ -85,6 +88,7 @@ public class BuildRequest { ...@@ -85,6 +88,7 @@ public class BuildRequest {
this.cleanCache = cleanCache; this.cleanCache = cleanCache;
this.verboseLogging = verboseLogging; this.verboseLogging = verboseLogging;
this.pullPolicy = pullPolicy; this.pullPolicy = pullPolicy;
this.publish = publish;
} }
/** /**
...@@ -95,7 +99,7 @@ public class BuildRequest { ...@@ -95,7 +99,7 @@ public class BuildRequest {
public BuildRequest withBuilder(ImageReference builder) { public BuildRequest withBuilder(ImageReference builder) {
Assert.notNull(builder, "Builder must not be null"); Assert.notNull(builder, "Builder must not be null");
return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage, 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 { ...@@ -105,7 +109,7 @@ public class BuildRequest {
*/ */
public BuildRequest withRunImage(ImageReference runImageName) { public BuildRequest withRunImage(ImageReference runImageName) {
return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(), 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 { ...@@ -116,7 +120,7 @@ public class BuildRequest {
public BuildRequest withCreator(Creator creator) { public BuildRequest withCreator(Creator creator) {
Assert.notNull(creator, "Creator must not be null"); Assert.notNull(creator, "Creator must not be null");
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env, 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 { ...@@ -131,7 +135,7 @@ public class BuildRequest {
Map<String, String> env = new LinkedHashMap<>(this.env); Map<String, String> env = new LinkedHashMap<>(this.env);
env.put(name, value); env.put(name, value);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, 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 { ...@@ -144,7 +148,8 @@ public class BuildRequest {
Map<String, String> updatedEnv = new LinkedHashMap<>(this.env); Map<String, String> updatedEnv = new LinkedHashMap<>(this.env);
updatedEnv.putAll(env); updatedEnv.putAll(env);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, 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 { ...@@ -154,7 +159,7 @@ public class BuildRequest {
*/ */
public BuildRequest withCleanCache(boolean cleanCache) { public BuildRequest withCleanCache(boolean cleanCache) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, 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 { ...@@ -164,7 +169,7 @@ public class BuildRequest {
*/ */
public BuildRequest withVerboseLogging(boolean verboseLogging) { public BuildRequest withVerboseLogging(boolean verboseLogging) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, 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 { ...@@ -174,7 +179,17 @@ public class BuildRequest {
*/ */
public BuildRequest withPullPolicy(PullPolicy pullPolicy) { public BuildRequest withPullPolicy(PullPolicy pullPolicy) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, 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 { ...@@ -244,6 +259,14 @@ public class BuildRequest {
return this.verboseLogging; 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 the image {@link PullPolicy} that the builder should use.
* @return image pull policy * @return image pull policy
......
...@@ -23,6 +23,7 @@ import org.springframework.boot.buildpack.platform.build.BuilderMetadata.Stack; ...@@ -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.DockerApi;
import org.springframework.boot.buildpack.platform.docker.TotalProgressEvent; import org.springframework.boot.buildpack.platform.docker.TotalProgressEvent;
import org.springframework.boot.buildpack.platform.docker.TotalProgressPullListener; 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.UpdateListener;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; 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.transport.DockerEngineException;
...@@ -45,6 +46,8 @@ public class Builder { ...@@ -45,6 +46,8 @@ public class Builder {
private final DockerApi docker; private final DockerApi docker;
private final DockerConfiguration dockerConfiguration;
/** /**
* Create a new builder instance. * Create a new builder instance.
*/ */
...@@ -66,7 +69,7 @@ public class Builder { ...@@ -66,7 +69,7 @@ public class Builder {
* @param log a logger used to record output * @param log a logger used to record output
*/ */
public Builder(BuildLog log) { public Builder(BuildLog log) {
this(log, new DockerApi()); this(log, new DockerApi(), null);
} }
/** /**
...@@ -76,13 +79,14 @@ public class Builder { ...@@ -76,13 +79,14 @@ public class Builder {
* @since 2.4.0 * @since 2.4.0
*/ */
public Builder(BuildLog log, DockerConfiguration dockerConfiguration) { 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"); Assert.notNull(log, "Log must not be null");
this.log = log; this.log = log;
this.docker = docker; this.docker = docker;
this.dockerConfiguration = dockerConfiguration;
} }
public void build(BuildRequest request) throws DockerEngineException, IOException { public void build(BuildRequest request) throws DockerEngineException, IOException {
...@@ -97,6 +101,9 @@ public class Builder { ...@@ -97,6 +101,9 @@ public class Builder {
this.docker.image().load(builder.getArchive(), UpdateListener.none()); this.docker.image().load(builder.getArchive(), UpdateListener.none());
try { try {
executeLifecycle(request, builder); executeLifecycle(request, builder);
if (request.isPublish()) {
pushImage(request.getName());
}
} }
finally { finally {
this.docker.image().remove(builder.getName(), true); this.docker.image().remove(builder.getName(), true);
...@@ -143,11 +150,28 @@ public class Builder { ...@@ -143,11 +150,28 @@ public class Builder {
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException { private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
Consumer<TotalProgressEvent> progressConsumer = this.log.pullingImage(reference, imageType); Consumer<TotalProgressEvent> progressConsumer = this.log.pullingImage(reference, imageType);
TotalProgressPullListener listener = new TotalProgressPullListener(progressConsumer); 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); this.log.pulledImage(image, imageType);
return image; 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) { private void assertStackIdsMatch(Image runImage, Image builderImage) {
StackId runImageStackId = StackId.fromImage(runImage); StackId runImageStackId = StackId.fromImage(runImage);
StackId builderImageStackId = StackId.fromImage(builderImage); StackId builderImageStackId = StackId.fromImage(builderImage);
......
...@@ -78,7 +78,7 @@ public class DockerApi { ...@@ -78,7 +78,7 @@ public class DockerApi {
* @since 2.4.0 * @since 2.4.0
*/ */
public DockerApi(DockerConfiguration dockerConfiguration) { public DockerApi(DockerConfiguration dockerConfiguration) {
this(HttpTransport.create(dockerConfiguration)); this(HttpTransport.create((dockerConfiguration != null) ? dockerConfiguration.getHost() : null));
} }
/** /**
...@@ -156,13 +156,26 @@ public class DockerApi { ...@@ -156,13 +156,26 @@ public class DockerApi {
* @throws IOException on IO error * @throws IOException on IO error
*/ */
public Image pull(ImageReference reference, UpdateListener<PullImageUpdateEvent> listener) throws IOException { 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(reference, "Reference must not be null");
Assert.notNull(listener, "Listener must not be null"); Assert.notNull(listener, "Listener must not be null");
URI createUri = buildUrl("/images/create", "fromImage", reference.toString()); URI createUri = buildUrl("/images/create", "fromImage", reference.toString());
DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener(); DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener();
listener.onStart(); listener.onStart();
try { try {
try (Response response = http().post(createUri)) { try (Response response = http().post(createUri, registryAuth)) {
jsonStream().get(response.getContent(), PullImageUpdateEvent.class, (event) -> { jsonStream().get(response.getContent(), PullImageUpdateEvent.class, (event) -> {
digestCapture.onUpdate(event); digestCapture.onUpdate(event);
listener.onUpdate(event); listener.onUpdate(event);
...@@ -175,6 +188,33 @@ public class DockerApi { ...@@ -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. * Load an {@link ImageArchive} into Docker.
* @param archive the archive to load * @param archive the archive to load
...@@ -398,4 +438,18 @@ public class DockerApi { ...@@ -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());
}
}
} }
/*
* 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;
}
}
...@@ -22,24 +22,14 @@ import com.fasterxml.jackson.annotation.JsonCreator; ...@@ -22,24 +22,14 @@ import com.fasterxml.jackson.annotation.JsonCreator;
* A {@link ProgressUpdateEvent} fired as an image is pulled. * A {@link ProgressUpdateEvent} fired as an image is pulled.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0 * @since 2.3.0
*/ */
public class PullImageUpdateEvent extends ProgressUpdateEvent { public class PullImageUpdateEvent extends ImageProgressUpdateEvent {
private final String id;
@JsonCreator @JsonCreator
public PullImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) { public PullImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) {
super(status, progressDetail, progress); super(id, 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;
} }
} }
/*
* 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;
}
}
}
/*
* 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());
}
}
}
...@@ -16,26 +16,19 @@ ...@@ -16,26 +16,19 @@
package org.springframework.boot.buildpack.platform.docker; package org.springframework.boot.buildpack.platform.docker;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer; 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 * {@link UpdateListener} that calculates the total progress of the entire pull operation
* and publishes {@link TotalProgressEvent}. * and publishes {@link TotalProgressEvent}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0 * @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 * Create a new {@link TotalProgressPullListener} that prints a progress bar to
...@@ -53,87 +46,7 @@ public class TotalProgressPullListener implements UpdateListener<PullImageUpdate ...@@ -53,87 +46,7 @@ public class TotalProgressPullListener implements UpdateListener<PullImageUpdate
* events} * events}
*/ */
public TotalProgressPullListener(Consumer<TotalProgressEvent> consumer) { public TotalProgressPullListener(Consumer<TotalProgressEvent> consumer) {
this.consumer = consumer; super(consumer, TRACKED_STATUS_KEYS);
}
@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);
}
} }
} }
/*
* 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);
}
}
...@@ -29,40 +29,65 @@ public final class DockerConfiguration { ...@@ -29,40 +29,65 @@ public final class DockerConfiguration {
private final DockerHost host; private final DockerHost host;
private final DockerRegistryAuthentication authentication; private final DockerRegistryAuthentication builderAuthentication;
private final DockerRegistryAuthentication publishAuthentication;
public DockerConfiguration() { 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.host = host;
this.authentication = authentication; this.builderAuthentication = builderAuthentication;
this.publishAuthentication = publishAuthentication;
} }
public DockerHost getHost() { public DockerHost getHost() {
return this.host; return this.host;
} }
public DockerRegistryAuthentication getRegistryAuthentication() { public DockerRegistryAuthentication getBuilderRegistryAuthentication() {
return this.authentication; return this.builderAuthentication;
}
public DockerRegistryAuthentication getPublishRegistryAuthentication() {
return this.publishAuthentication;
} }
public DockerConfiguration withHost(String address, boolean secure, String certificatePath) { public DockerConfiguration withHost(String address, boolean secure, String certificatePath) {
Assert.notNull(address, "Address must not be null"); 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 withBuilderRegistryTokenAuthentication(String token) {
Assert.notNull(token, "Token must not be null");
return new DockerConfiguration(this.host, new DockerRegistryTokenAuthentication(token),
this.publishAuthentication);
}
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),
this.publishAuthentication);
} }
public DockerConfiguration withRegistryTokenAuthentication(String token) { public DockerConfiguration withPublishRegistryTokenAuthentication(String token) {
Assert.notNull(token, "Token must not be null"); Assert.notNull(token, "Token must not be null");
return new DockerConfiguration(this.host, new DockerRegistryTokenAuthentication(token)); return new DockerConfiguration(this.host, this.builderAuthentication,
new DockerRegistryTokenAuthentication(token));
} }
public DockerConfiguration withRegistryUserAuthentication(String username, String password, String url, public DockerConfiguration withPublishRegistryUserAuthentication(String username, String password, String url,
String email) { String email) {
Assert.notNull(username, "Username must not be null"); Assert.notNull(username, "Username must not be null");
Assert.notNull(password, "Password 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, this.builderAuthentication,
new DockerRegistryUserAuthentication(username, password, url, email));
} }
} }
...@@ -30,7 +30,7 @@ public class DockerHost { ...@@ -30,7 +30,7 @@ public class DockerHost {
private final String certificatePath; private final String certificatePath;
protected DockerHost(String address, boolean secure, String certificatePath) { public DockerHost(String address, boolean secure, String certificatePath) {
this.address = address; this.address = address;
this.secure = secure; this.secure = secure;
this.certificatePath = certificatePath; this.certificatePath = certificatePath;
......
...@@ -25,9 +25,9 @@ package org.springframework.boot.buildpack.platform.docker.configuration; ...@@ -25,9 +25,9 @@ package org.springframework.boot.buildpack.platform.docker.configuration;
public interface DockerRegistryAuthentication { 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 * @return the auth header
*/ */
String createAuthHeader(); String getAuthHeader();
} }
...@@ -30,6 +30,7 @@ class DockerRegistryTokenAuthentication extends JsonEncodedDockerRegistryAuthent ...@@ -30,6 +30,7 @@ class DockerRegistryTokenAuthentication extends JsonEncodedDockerRegistryAuthent
DockerRegistryTokenAuthentication(String token) { DockerRegistryTokenAuthentication(String token) {
this.token = token; this.token = token;
createAuthHeader();
} }
String getToken() { String getToken() {
......
...@@ -42,6 +42,7 @@ class DockerRegistryUserAuthentication extends JsonEncodedDockerRegistryAuthenti ...@@ -42,6 +42,7 @@ class DockerRegistryUserAuthentication extends JsonEncodedDockerRegistryAuthenti
this.password = password; this.password = password;
this.url = url; this.url = url;
this.email = email; this.email = email;
createAuthHeader();
} }
String getUsername() { String getUsername() {
......
...@@ -22,17 +22,23 @@ import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; ...@@ -22,17 +22,23 @@ import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
import org.springframework.util.Base64Utils; import org.springframework.util.Base64Utils;
/** /**
* {@link DockerRegistryAuthentication} that uses creates a Base64 encoded auth header * {@link DockerRegistryAuthentication} that uses a Base64 encoded auth header value based
* value based on the JSON created from the instance. * on the JSON created from the instance.
* *
* @author Scott Frederick * @author Scott Frederick
*/ */
class JsonEncodedDockerRegistryAuthentication implements DockerRegistryAuthentication { class JsonEncodedDockerRegistryAuthentication implements DockerRegistryAuthentication {
private String authHeader;
@Override @Override
public String createAuthHeader() { public String getAuthHeader() {
return this.authHeader;
}
protected void createAuthHeader() {
try { try {
return Base64Utils.encodeToUrlSafeString(SharedObjectMapper.get().writeValueAsBytes(this)); this.authHeader = Base64Utils.encodeToUrlSafeString(SharedObjectMapper.get().writeValueAsBytes(this));
} }
catch (JsonProcessingException ex) { catch (JsonProcessingException ex) {
throw new IllegalStateException("Error creating Docker registry authentication header", ex); throw new IllegalStateException("Error creating Docker registry authentication header", ex);
......
...@@ -36,7 +36,6 @@ import org.apache.http.client.methods.HttpUriRequest; ...@@ -36,7 +36,6 @@ import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.AbstractHttpEntity; import org.apache.http.entity.AbstractHttpEntity;
import org.apache.http.impl.client.CloseableHttpClient; 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.Content;
import org.springframework.boot.buildpack.platform.io.IOConsumer; import org.springframework.boot.buildpack.platform.io.IOConsumer;
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
...@@ -53,19 +52,17 @@ import org.springframework.util.StringUtils; ...@@ -53,19 +52,17 @@ import org.springframework.util.StringUtils;
*/ */
abstract class HttpClientTransport implements HttpTransport { abstract class HttpClientTransport implements HttpTransport {
static final String REGISTRY_AUTH_HEADER = "X-Registry-Auth";
private final CloseableHttpClient client; private final CloseableHttpClient client;
private final HttpHost host; private final HttpHost host;
private final String registryAuthHeader; protected HttpClientTransport(CloseableHttpClient client, HttpHost host) {
protected HttpClientTransport(CloseableHttpClient client, HttpHost host,
DockerRegistryAuthentication authentication) {
Assert.notNull(client, "Client must not be null"); Assert.notNull(client, "Client must not be null");
Assert.notNull(host, "Host must not be null"); Assert.notNull(host, "Host must not be null");
this.client = client; this.client = client;
this.host = host; this.host = host;
this.registryAuthHeader = buildRegistryAuthHeader(authentication);
} }
/** /**
...@@ -88,6 +85,17 @@ abstract class HttpClientTransport implements HttpTransport { ...@@ -88,6 +85,17 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(new HttpPost(uri)); 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. * Perform a HTTP POST operation.
* @param uri the destination URI * @param uri the destination URI
...@@ -122,11 +130,6 @@ abstract class HttpClientTransport implements HttpTransport { ...@@ -122,11 +130,6 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(new HttpDelete(uri)); 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, private Response execute(HttpEntityEnclosingRequestBase request, String contentType,
IOConsumer<OutputStream> writer) { IOConsumer<OutputStream> writer) {
request.setHeader(HttpHeaders.CONTENT_TYPE, contentType); request.setHeader(HttpHeaders.CONTENT_TYPE, contentType);
...@@ -134,11 +137,15 @@ abstract class HttpClientTransport implements HttpTransport { ...@@ -134,11 +137,15 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(request); 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) { private Response execute(HttpUriRequest request) {
try { try {
if (this.registryAuthHeader != null) {
request.addHeader("X-Registry-Auth", this.registryAuthHeader);
}
CloseableHttpResponse response = this.client.execute(this.host, request); CloseableHttpResponse response = this.client.execute(this.host, request);
StatusLine statusLine = response.getStatusLine(); StatusLine statusLine = response.getStatusLine();
int statusCode = statusLine.getStatusCode(); int statusCode = statusLine.getStatusCode();
......
...@@ -23,6 +23,7 @@ import java.io.OutputStream; ...@@ -23,6 +23,7 @@ import java.io.OutputStream;
import java.net.URI; import java.net.URI;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; 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.io.IOConsumer;
import org.springframework.boot.buildpack.platform.system.Environment; import org.springframework.boot.buildpack.platform.system.Environment;
...@@ -51,6 +52,15 @@ public interface HttpTransport { ...@@ -51,6 +52,15 @@ public interface HttpTransport {
*/ */
Response post(URI uri) throws IOException; 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. * Perform a HTTP POST operation.
* @param uri the destination URI (excluding any host/port) * @param uri the destination URI (excluding any host/port)
...@@ -85,17 +95,17 @@ public interface HttpTransport { ...@@ -85,17 +95,17 @@ public interface HttpTransport {
* @return a {@link HttpTransport} instance * @return a {@link HttpTransport} instance
*/ */
static HttpTransport create() { static HttpTransport create() {
return create(new DockerConfiguration()); return create(Environment.SYSTEM);
} }
/** /**
* Create the most suitable {@link HttpTransport} based on the * Create the most suitable {@link HttpTransport} based on the
* {@link Environment#SYSTEM system environment}. * {@link Environment#SYSTEM system environment}.
* @param dockerConfiguration the Docker engine configuration * @param dockerHost the Docker engine host configuration
* @return a {@link HttpTransport} instance * @return a {@link HttpTransport} instance
*/ */
static HttpTransport create(DockerConfiguration dockerConfiguration) { static HttpTransport create(DockerHost dockerHost) {
return create(Environment.SYSTEM, dockerConfiguration); return create(Environment.SYSTEM, dockerHost);
} }
/** /**
...@@ -105,19 +115,19 @@ public interface HttpTransport { ...@@ -105,19 +115,19 @@ public interface HttpTransport {
* @return a {@link HttpTransport} instance * @return a {@link HttpTransport} instance
*/ */
static HttpTransport create(Environment environment) { static HttpTransport create(Environment environment) {
return create(environment, new DockerConfiguration()); return create(environment, null);
} }
/** /**
* Create the most suitable {@link HttpTransport} based on the given * Create the most suitable {@link HttpTransport} based on the given
* {@link Environment} and {@link DockerConfiguration}. * {@link Environment} and {@link DockerConfiguration}.
* @param environment the source environment * @param environment the source environment
* @param dockerConfiguration the Docker engine configuration * @param dockerHost the Docker engine host configuration
* @return a {@link HttpTransport} instance * @return a {@link HttpTransport} instance
*/ */
static HttpTransport create(Environment environment, DockerConfiguration dockerConfiguration) { static HttpTransport create(Environment environment, DockerHost dockerHost) {
HttpTransport remote = RemoteHttpClientTransport.createIfPossible(environment, dockerConfiguration); HttpTransport remote = RemoteHttpClientTransport.createIfPossible(environment, dockerHost);
return (remote != null) ? remote : LocalHttpClientTransport.create(environment, dockerConfiguration); return (remote != null) ? remote : LocalHttpClientTransport.create(environment);
} }
/** /**
......
...@@ -38,8 +38,6 @@ import org.apache.http.impl.conn.BasicHttpClientConnectionManager; ...@@ -38,8 +38,6 @@ import org.apache.http.impl.conn.BasicHttpClientConnectionManager;
import org.apache.http.protocol.HttpContext; import org.apache.http.protocol.HttpContext;
import org.apache.http.util.Args; 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.DomainSocket;
import org.springframework.boot.buildpack.platform.socket.NamedPipeSocket; import org.springframework.boot.buildpack.platform.socket.NamedPipeSocket;
import org.springframework.boot.buildpack.platform.system.Environment; import org.springframework.boot.buildpack.platform.system.Environment;
...@@ -58,16 +56,15 @@ final class LocalHttpClientTransport extends HttpClientTransport { ...@@ -58,16 +56,15 @@ final class LocalHttpClientTransport extends HttpClientTransport {
private static final HttpHost LOCAL_DOCKER_HOST = HttpHost.create("docker://localhost"); private static final HttpHost LOCAL_DOCKER_HOST = HttpHost.create("docker://localhost");
private LocalHttpClientTransport(CloseableHttpClient client, DockerRegistryAuthentication authentication) { private LocalHttpClientTransport(CloseableHttpClient client) {
super(client, LOCAL_DOCKER_HOST, authentication); super(client, LOCAL_DOCKER_HOST);
} }
static LocalHttpClientTransport create(Environment environment, DockerConfiguration dockerConfiguration) { static LocalHttpClientTransport create(Environment environment) {
HttpClientBuilder builder = HttpClients.custom(); HttpClientBuilder builder = HttpClients.custom();
builder.setConnectionManager(new LocalConnectionManager(socketFilePath(environment))); builder.setConnectionManager(new LocalConnectionManager(socketFilePath(environment)));
builder.setSchemePortResolver(new LocalSchemePortResolver()); builder.setSchemePortResolver(new LocalSchemePortResolver());
return new LocalHttpClientTransport(builder.build(), return new LocalHttpClientTransport(builder.build());
(dockerConfiguration != null) ? dockerConfiguration.getRegistryAuthentication() : null);
} }
private static String socketFilePath(Environment environment) { private static String socketFilePath(Environment environment) {
......
...@@ -28,9 +28,7 @@ import org.apache.http.impl.client.CloseableHttpClient; ...@@ -28,9 +28,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients; 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.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.docker.ssl.SslContextFactory;
import org.springframework.boot.buildpack.platform.system.Environment; import org.springframework.boot.buildpack.platform.system.Environment;
import org.springframework.util.Assert; import org.springframework.util.Assert;
...@@ -51,23 +49,21 @@ final class RemoteHttpClientTransport extends HttpClientTransport { ...@@ -51,23 +49,21 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
private static final String DOCKER_CERT_PATH = "DOCKER_CERT_PATH"; private static final String DOCKER_CERT_PATH = "DOCKER_CERT_PATH";
private RemoteHttpClientTransport(CloseableHttpClient client, HttpHost host, private RemoteHttpClientTransport(CloseableHttpClient client, HttpHost host) {
DockerRegistryAuthentication authentication) { super(client, host);
super(client, host, authentication);
} }
static RemoteHttpClientTransport createIfPossible(Environment environment, static RemoteHttpClientTransport createIfPossible(Environment environment, DockerHost dockerHost) {
DockerConfiguration dockerConfiguration) { return createIfPossible(environment, dockerHost, new SslContextFactory());
return createIfPossible(environment, dockerConfiguration, new SslContextFactory());
} }
static RemoteHttpClientTransport createIfPossible(Environment environment, DockerConfiguration dockerConfiguration, static RemoteHttpClientTransport createIfPossible(Environment environment, DockerHost dockerHost,
SslContextFactory sslContextFactory) { SslContextFactory sslContextFactory) {
DockerHost host = getHost(environment, dockerConfiguration); DockerHost host = getHost(environment, dockerHost);
if (host == null || host.getAddress() == null || isLocalFileReference(host.getAddress())) { if (host == null || host.getAddress() == null || isLocalFileReference(host.getAddress())) {
return null; 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) { private static boolean isLocalFileReference(String host) {
...@@ -80,16 +76,15 @@ final class RemoteHttpClientTransport extends HttpClientTransport { ...@@ -80,16 +76,15 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
} }
} }
private static RemoteHttpClientTransport create(DockerHost host, DockerConfiguration dockerConfiguration, private static RemoteHttpClientTransport create(DockerHost host, SslContextFactory sslContextFactory,
SslContextFactory sslContextFactory, HttpHost tcpHost) { HttpHost tcpHost) {
HttpClientBuilder builder = HttpClients.custom(); HttpClientBuilder builder = HttpClients.custom();
if (host.isSecure()) { if (host.isSecure()) {
builder.setSSLSocketFactory(getSecureConnectionSocketFactory(host, sslContextFactory)); builder.setSSLSocketFactory(getSecureConnectionSocketFactory(host, sslContextFactory));
} }
String scheme = host.isSecure() ? "https" : "http"; String scheme = host.isSecure() ? "https" : "http";
HttpHost httpHost = new HttpHost(tcpHost.getHostName(), tcpHost.getPort(), scheme); HttpHost httpHost = new HttpHost(tcpHost.getHostName(), tcpHost.getPort(), scheme);
return new RemoteHttpClientTransport(builder.build(), httpHost, return new RemoteHttpClientTransport(builder.build(), httpHost);
(dockerConfiguration != null) ? dockerConfiguration.getRegistryAuthentication() : null);
} }
private static LayeredConnectionSocketFactory getSecureConnectionSocketFactory(DockerHost host, private static LayeredConnectionSocketFactory getSecureConnectionSocketFactory(DockerHost host,
...@@ -101,14 +96,11 @@ final class RemoteHttpClientTransport extends HttpClientTransport { ...@@ -101,14 +96,11 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
return new SSLConnectionSocketFactory(sslContext); 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) { if (environment.get(DOCKER_HOST) != null) {
return new EnvironmentDockerHost(environment); return new EnvironmentDockerHost(environment);
} }
if (dockerConfiguration != null && dockerConfiguration.getHost() != null) { return dockerHost;
return dockerConfiguration.getHost();
}
return null;
} }
private static class EnvironmentDockerHost extends DockerHost { private static class EnvironmentDockerHost extends DockerHost {
......
...@@ -30,6 +30,7 @@ import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi ...@@ -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.ImageApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
import org.springframework.boot.buildpack.platform.docker.TotalProgressPullListener; 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.transport.DockerEngineException;
import org.springframework.boot.buildpack.platform.docker.type.ContainerReference; import org.springframework.boot.buildpack.platform.docker.type.ContainerReference;
import org.springframework.boot.buildpack.platform.docker.type.ContainerStatus; import org.springframework.boot.buildpack.platform.docker.type.ContainerStatus;
...@@ -44,11 +45,13 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException ...@@ -44,11 +45,13 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/** /**
* Tests for {@link Builder}. * Tests for {@link Builder}.
...@@ -83,18 +86,53 @@ class BuilderTests { ...@@ -83,18 +86,53 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json"); Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-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)); .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)); .willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker); Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest(); BuildRequest request = getTestRequest();
builder.build(request); builder.build(request);
assertThat(out.toString()).contains("Running creator"); assertThat(out.toString()).contains("Running creator");
assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class); 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()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true); 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 @Test
...@@ -103,11 +141,11 @@ class BuilderTests { ...@@ -103,11 +141,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image-with-no-run-image-tag.json"); Image builderImage = loadImage("image-with-no-run-image-tag.json");
Image runImage = loadImage("run-image.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)); .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)); .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")); BuildRequest request = getTestRequest().withBuilder(ImageReference.of("gcr.io/paketo-buildpacks/builder"));
builder.build(request); builder.build(request);
assertThat(out.toString()).contains("Running creator"); assertThat(out.toString()).contains("Running creator");
...@@ -123,12 +161,12 @@ class BuilderTests { ...@@ -123,12 +161,12 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image-with-run-image-digest.json"); Image builderImage = loadImage("image-with-run-image-digest.json");
Image runImage = loadImage("run-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)); .willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of( given(docker.image().pull(eq(ImageReference.of(
"docker.io/cloudfoundry/run@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")), "docker.io/cloudfoundry/run@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
any())).willAnswer(withPulledImage(runImage)); 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(); BuildRequest request = getTestRequest();
builder.build(request); builder.build(request);
assertThat(out.toString()).contains("Running creator"); assertThat(out.toString()).contains("Running creator");
...@@ -144,11 +182,11 @@ class BuilderTests { ...@@ -144,11 +182,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json"); Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-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)); .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)); .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")); BuildRequest request = getTestRequest().withRunImage(ImageReference.of("example.com/custom/run:latest"));
builder.build(request); builder.build(request);
assertThat(out.toString()).contains("Running creator"); assertThat(out.toString()).contains("Running creator");
...@@ -164,15 +202,15 @@ class BuilderTests { ...@@ -164,15 +202,15 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json"); Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-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)); .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)); .willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)))) given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME))))
.willReturn(builderImage); .willReturn(builderImage);
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")))) given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))
.willReturn(runImage); .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); BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.NEVER);
builder.build(request); builder.build(request);
assertThat(out.toString()).contains("Running creator"); assertThat(out.toString()).contains("Running creator");
...@@ -190,15 +228,15 @@ class BuilderTests { ...@@ -190,15 +228,15 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json"); Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-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)); .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)); .willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)))) given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME))))
.willReturn(builderImage); .willReturn(builderImage);
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")))) given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))
.willReturn(runImage); .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); BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.ALWAYS);
builder.build(request); builder.build(request);
assertThat(out.toString()).contains("Running creator"); assertThat(out.toString()).contains("Running creator");
...@@ -206,7 +244,7 @@ class BuilderTests { ...@@ -206,7 +244,7 @@ class BuilderTests {
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class); ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).load(archive.capture(), any()); verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true); 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()); verify(docker.image(), never()).inspect(any());
} }
...@@ -216,9 +254,9 @@ class BuilderTests { ...@@ -216,9 +254,9 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json"); Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-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)); .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)); .willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)))).willThrow( 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)) new DockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, null))
...@@ -226,7 +264,7 @@ class BuilderTests { ...@@ -226,7 +264,7 @@ class BuilderTests {
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")))).willThrow( 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)) new DockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, null))
.willReturn(runImage); .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); BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.IF_NOT_PRESENT);
builder.build(request); builder.build(request);
assertThat(out.toString()).contains("Running creator"); assertThat(out.toString()).contains("Running creator");
...@@ -235,7 +273,7 @@ class BuilderTests { ...@@ -235,7 +273,7 @@ class BuilderTests {
verify(docker.image()).load(archive.capture(), any()); verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true); verify(docker.image()).remove(archive.getValue().getTag(), true);
verify(docker.image(), times(2)).inspect(any()); 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 @Test
...@@ -244,11 +282,11 @@ class BuilderTests { ...@@ -244,11 +282,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi(); DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json"); Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image-with-bad-stack.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)); .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)); .willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker); Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest(); BuildRequest request = getTestRequest();
assertThatIllegalStateException().isThrownBy(() -> builder.build(request)).withMessage( assertThatIllegalStateException().isThrownBy(() -> builder.build(request)).withMessage(
"Run image stack 'org.cloudfoundry.stacks.cfwindowsfs3' does not match builder stack 'io.buildpacks.stacks.bionic'"); "Run image stack 'org.cloudfoundry.stacks.cfwindowsfs3' does not match builder stack 'io.buildpacks.stacks.bionic'");
...@@ -260,11 +298,11 @@ class BuilderTests { ...@@ -260,11 +298,11 @@ class BuilderTests {
DockerApi docker = mockDockerApiLifecycleError(); DockerApi docker = mockDockerApiLifecycleError();
Image builderImage = loadImage("image.json"); Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-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)); .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)); .willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker); Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest(); BuildRequest request = getTestRequest();
assertThatExceptionOfType(BuilderException.class).isThrownBy(() -> builder.build(request)) assertThatExceptionOfType(BuilderException.class).isThrownBy(() -> builder.build(request))
.withMessage("Builder lifecycle 'creator' failed with status code 9"); .withMessage("Builder lifecycle 'creator' failed with status code 9");
......
...@@ -54,6 +54,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException ...@@ -54,6 +54,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
...@@ -127,6 +128,9 @@ class DockerApiTests { ...@@ -127,6 +128,9 @@ class DockerApiTests {
@Mock @Mock
private UpdateListener<PullImageUpdateEvent> pullListener; private UpdateListener<PullImageUpdateEvent> pullListener;
@Mock
private UpdateListener<PushImageUpdateEvent> pushListener;
@Mock @Mock
private UpdateListener<LoadImageUpdateEvent> loadListener; private UpdateListener<LoadImageUpdateEvent> loadListener;
...@@ -156,7 +160,7 @@ class DockerApiTests { ...@@ -156,7 +160,7 @@ class DockerApiTests {
URI createUri = new URI(IMAGES_URL + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase"); URI createUri = new URI(IMAGES_URL + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase");
String imageHash = "4acb6bfd6c4f0cabaf7f3690e444afe51f1c7de54d51da7e63fac709c56f1c30"; String imageHash = "4acb6bfd6c4f0cabaf7f3690e444afe51f1c7de54d51da7e63fac709c56f1c30";
URI imageUri = new URI(IMAGES_URL + "/gcr.io/paketo-buildpacks/builder@sha256:" + imageHash + "/json"); 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")); given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.pull(reference, this.pullListener); Image image = this.api.pull(reference, this.pullListener);
assertThat(image.getLayers()).hasSize(46); assertThat(image.getLayers()).hasSize(46);
...@@ -166,6 +170,57 @@ class DockerApiTests { ...@@ -166,6 +170,57 @@ class DockerApiTests {
ordered.verify(this.pullListener).onFinish(); 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 @Test
void loadWhenArchiveIsNullThrowsException() { void loadWhenArchiveIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.load(null, UpdateListener.none())) assertThatIllegalArgumentException().isThrownBy(() -> this.api.load(null, UpdateListener.none()))
......
...@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat; ...@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link LoadImageUpdateEvent}. * Tests for {@link LoadImageUpdateEvent}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
*/ */
class LoadImageUpdateEventTests extends ProgressUpdateEventTests { class LoadImageUpdateEventTests extends ProgressUpdateEventTests<LoadImageUpdateEvent> {
@Test @Test
void getStreamReturnsStream() { void getStreamReturnsStream() {
LoadImageUpdateEvent event = (LoadImageUpdateEvent) createEvent(); LoadImageUpdateEvent event = createEvent();
assertThat(event.getStream()).isEqualTo("stream"); assertThat(event.getStream()).isEqualTo("stream");
} }
@Override @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); return new LoadImageUpdateEvent("stream", status, progressDetail, progress);
} }
......
...@@ -26,8 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat; ...@@ -26,8 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link ProgressUpdateEvent}. * Tests for {@link ProgressUpdateEvent}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
*/ */
abstract class ProgressUpdateEventTests { abstract class ProgressUpdateEventTests<E extends ProgressUpdateEvent> {
@Test @Test
void getStatusReturnsStatus() { void getStatusReturnsStatus() {
...@@ -66,10 +67,10 @@ abstract class ProgressUpdateEventTests { ...@@ -66,10 +67,10 @@ abstract class ProgressUpdateEventTests {
assertThat(ProgressDetail.isEmpty(detail)).isFalse(); assertThat(ProgressDetail.isEmpty(detail)).isFalse();
} }
protected ProgressUpdateEvent createEvent() { protected E createEvent() {
return createEvent("status", new ProgressDetail(1, 2), "progress"); 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);
} }
...@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat; ...@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link PullImageUpdateEvent}. * Tests for {@link PullImageUpdateEvent}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
*/ */
class PullImageUpdateEventTests extends ProgressUpdateEventTests { class PullImageUpdateEventTests extends ProgressUpdateEventTests<PullImageUpdateEvent> {
@Test @Test
void getIdReturnsId() { void getIdReturnsId() {
PullImageUpdateEvent event = (PullImageUpdateEvent) createEvent(); PullImageUpdateEvent event = createEvent();
assertThat(event.getId()).isEqualTo("id"); assertThat(event.getId()).isEqualTo("id");
} }
@Override @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); return new PullImageUpdateEvent("id", status, progressDetail, progress);
} }
......
/*
* 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);
}
}
...@@ -21,6 +21,7 @@ import java.util.ArrayList; ...@@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Consumer; import java.util.function.Consumer;
import com.fasterxml.jackson.annotation.JsonCreator;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
...@@ -33,13 +34,14 @@ import static org.assertj.core.api.Assertions.assertThat; ...@@ -33,13 +34,14 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link TotalProgressPullListener}. * Tests for {@link TotalProgressPullListener}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
*/ */
class TotalProgressPullListenerTests extends AbstractJsonTests { class TotalProgressListenerTests extends AbstractJsonTests {
@Test @Test
void totalProgress() throws Exception { void totalProgress() throws Exception {
List<Integer> progress = new ArrayList<>(); 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); run(listener);
int last = 0; int last = 0;
for (Integer update : progress) { for (Integer update : progress) {
...@@ -52,26 +54,25 @@ class TotalProgressPullListenerTests extends AbstractJsonTests { ...@@ -52,26 +54,25 @@ class TotalProgressPullListenerTests extends AbstractJsonTests {
@Test @Test
@Disabled("For visual inspection") @Disabled("For visual inspection")
void totalProgressUpdatesSmoothly() throws Exception { void totalProgressUpdatesSmoothly() throws Exception {
TestTotalProgressPullListener listener = new TestTotalProgressPullListener( TestTotalProgressListener listener = new TestTotalProgressListener(new TotalProgressBar("Pulling layers:"));
new TotalProgressBar("Pulling layers:"));
run(listener); run(listener);
} }
private void run(TotalProgressPullListener listener) throws IOException { private void run(TestTotalProgressListener listener) throws IOException {
JsonStream jsonStream = new JsonStream(getObjectMapper()); JsonStream jsonStream = new JsonStream(getObjectMapper());
listener.onStart(); listener.onStart();
jsonStream.get(getContent("pull-stream.json"), PullImageUpdateEvent.class, listener::onUpdate); jsonStream.get(getContent("pull-stream.json"), TestImageUpdateEvent.class, listener::onUpdate);
listener.onFinish(); listener.onFinish();
} }
private static class TestTotalProgressPullListener extends TotalProgressPullListener { private static class TestTotalProgressListener extends TotalProgressListener<TestImageUpdateEvent> {
TestTotalProgressPullListener(Consumer<TotalProgressEvent> consumer) { TestTotalProgressListener(Consumer<TotalProgressEvent> consumer) {
super(consumer); super(consumer, new String[] { "Pulling", "Downloading", "Extracting" });
} }
@Override @Override
public void onUpdate(PullImageUpdateEvent event) { public void onUpdate(TestImageUpdateEvent event) {
super.onUpdate(event); super.onUpdate(event);
try { try {
Thread.sleep(10); Thread.sleep(10);
...@@ -82,4 +83,13 @@ class TotalProgressPullListenerTests extends AbstractJsonTests { ...@@ -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);
}
}
} }
...@@ -31,14 +31,14 @@ public class DockerConfigurationTests { ...@@ -31,14 +31,14 @@ public class DockerConfigurationTests {
@Test @Test
void createDockerConfigurationWithDefaults() { void createDockerConfigurationWithDefaults() {
DockerConfiguration configuration = new DockerConfiguration(); DockerConfiguration configuration = new DockerConfiguration();
assertThat(configuration.getRegistryAuthentication()).isNull(); assertThat(configuration.getBuilderRegistryAuthentication()).isNull();
} }
@Test @Test
void createDockerConfigurationWithUserAuth() { void createDockerConfigurationWithUserAuth() {
DockerConfiguration configuration = new DockerConfiguration().withRegistryUserAuthentication("user", "secret", DockerConfiguration configuration = new DockerConfiguration().withBuilderRegistryUserAuthentication("user",
"https://docker.example.com", "docker@example.com"); "secret", "https://docker.example.com", "docker@example.com");
DockerRegistryAuthentication auth = configuration.getRegistryAuthentication(); DockerRegistryAuthentication auth = configuration.getBuilderRegistryAuthentication();
assertThat(auth).isNotNull(); assertThat(auth).isNotNull();
assertThat(auth).isInstanceOf(DockerRegistryUserAuthentication.class); assertThat(auth).isInstanceOf(DockerRegistryUserAuthentication.class);
DockerRegistryUserAuthentication userAuth = (DockerRegistryUserAuthentication) auth; DockerRegistryUserAuthentication userAuth = (DockerRegistryUserAuthentication) auth;
...@@ -50,8 +50,8 @@ public class DockerConfigurationTests { ...@@ -50,8 +50,8 @@ public class DockerConfigurationTests {
@Test @Test
void createDockerConfigurationWithTokenAuth() { void createDockerConfigurationWithTokenAuth() {
DockerConfiguration configuration = new DockerConfiguration().withRegistryTokenAuthentication("token"); DockerConfiguration configuration = new DockerConfiguration().withBuilderRegistryTokenAuthentication("token");
DockerRegistryAuthentication auth = configuration.getRegistryAuthentication(); DockerRegistryAuthentication auth = configuration.getBuilderRegistryAuthentication();
assertThat(auth).isNotNull(); assertThat(auth).isNotNull();
assertThat(auth).isInstanceOf(DockerRegistryTokenAuthentication.class); assertThat(auth).isInstanceOf(DockerRegistryTokenAuthentication.class);
DockerRegistryTokenAuthentication tokenAuth = (DockerRegistryTokenAuthentication) auth; DockerRegistryTokenAuthentication tokenAuth = (DockerRegistryTokenAuthentication) auth;
......
...@@ -29,13 +29,15 @@ import org.springframework.util.StreamUtils; ...@@ -29,13 +29,15 @@ import org.springframework.util.StreamUtils;
/** /**
* Tests for {@link DockerRegistryTokenAuthentication}. * Tests for {@link DockerRegistryTokenAuthentication}.
*
* @author Scott Frederick
*/ */
class DockerRegistryTokenAuthenticationTests extends AbstractJsonTests { class DockerRegistryTokenAuthenticationTests extends AbstractJsonTests {
@Test @Test
void createAuthHeaderReturnsEncodedHeader() throws IOException, JSONException { void createAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryTokenAuthentication auth = new DockerRegistryTokenAuthentication("tokenvalue"); DockerRegistryTokenAuthentication auth = new DockerRegistryTokenAuthentication("tokenvalue");
String header = auth.createAuthHeader(); String header = auth.getAuthHeader();
String expectedJson = StreamUtils.copyToString(getContent("auth-token.json"), StandardCharsets.UTF_8); String expectedJson = StreamUtils.copyToString(getContent("auth-token.json"), StandardCharsets.UTF_8);
JSONAssert.assertEquals(expectedJson, new String(Base64Utils.decodeFromUrlSafeString(header)), false); JSONAssert.assertEquals(expectedJson, new String(Base64Utils.decodeFromUrlSafeString(header)), false);
} }
......
...@@ -29,6 +29,8 @@ import org.springframework.util.StreamUtils; ...@@ -29,6 +29,8 @@ import org.springframework.util.StreamUtils;
/** /**
* Tests for {@link DockerRegistryUserAuthentication}. * Tests for {@link DockerRegistryUserAuthentication}.
*
* @author Scott Frederick
*/ */
class DockerRegistryUserAuthenticationTests extends AbstractJsonTests { class DockerRegistryUserAuthenticationTests extends AbstractJsonTests {
...@@ -36,13 +38,13 @@ class DockerRegistryUserAuthenticationTests extends AbstractJsonTests { ...@@ -36,13 +38,13 @@ class DockerRegistryUserAuthenticationTests extends AbstractJsonTests {
void createMinimalAuthHeaderReturnsEncodedHeader() throws IOException, JSONException { void createMinimalAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret", DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret",
"https://docker.example.com", "docker@example.com"); "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 @Test
void createFullAuthHeaderReturnsEncodedHeader() throws IOException, JSONException { void createFullAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret", null, null); 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 { private String jsonContent(String s) throws IOException {
......
...@@ -22,7 +22,6 @@ import java.io.InputStream; ...@@ -22,7 +22,6 @@ import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import org.apache.http.Header;
import org.apache.http.HttpEntity; import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest; import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHeaders; import org.apache.http.HttpHeaders;
...@@ -44,9 +43,7 @@ import org.mockito.Captor; ...@@ -44,9 +43,7 @@ import org.mockito.Captor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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.boot.buildpack.platform.docker.transport.HttpTransport.Response;
import org.springframework.util.Base64Utils;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
...@@ -123,6 +120,37 @@ class HttpClientTransportTests { ...@@ -123,6 +120,37 @@ class HttpClientTransportTests {
assertThat(request).isInstanceOf(HttpPost.class); assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri); assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull(); 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); assertThat(response.getContent()).isSameAs(this.content);
} }
...@@ -237,47 +265,6 @@ class HttpClientTransportTests { ...@@ -237,47 +265,6 @@ class HttpClientTransportTests {
.satisfies((ex) -> assertThat(ex.getMessage()).contains("test IO exception")); .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 { private String writeToString(HttpEntity entity) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
entity.writeTo(out); entity.writeTo(out);
...@@ -296,11 +283,7 @@ class HttpClientTransportTests { ...@@ -296,11 +283,7 @@ class HttpClientTransportTests {
static class TestHttpClientTransport extends HttpClientTransport { static class TestHttpClientTransport extends HttpClientTransport {
protected TestHttpClientTransport(CloseableHttpClient client) { protected TestHttpClientTransport(CloseableHttpClient client) {
super(client, HttpHost.create("docker://localhost"), null); super(client, HttpHost.create("docker://localhost"));
}
protected TestHttpClientTransport(CloseableHttpClient client, DockerConfiguration dockerConfiguration) {
super(client, HttpHost.create("docker://localhost"), dockerConfiguration.getRegistryAuthentication());
} }
} }
......
...@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test; ...@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; 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 org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
...@@ -52,7 +53,7 @@ class RemoteHttpClientTransportTests { ...@@ -52,7 +53,7 @@ class RemoteHttpClientTransportTests {
@Test @Test
void createIfPossibleWhenDockerHostIsNotSetReturnsNull() { void createIfPossibleWhenDockerHostIsNotSetReturnsNull() {
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration); new DockerHost(null, false, null));
assertThat(transport).isNull(); assertThat(transport).isNull();
} }
...@@ -67,8 +68,7 @@ class RemoteHttpClientTransportTests { ...@@ -67,8 +68,7 @@ class RemoteHttpClientTransportTests {
String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath() String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath()
.toString(); .toString();
this.environment.put("DOCKER_HOST", dummySocketFilePath); this.environment.put("DOCKER_HOST", dummySocketFilePath);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
this.dockerConfiguration);
assertThat(transport).isNull(); assertThat(transport).isNull();
} }
...@@ -77,22 +77,21 @@ class RemoteHttpClientTransportTests { ...@@ -77,22 +77,21 @@ class RemoteHttpClientTransportTests {
String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath() String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath()
.toString(); .toString();
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost(dummySocketFilePath, false, null)); new DockerHost(dummySocketFilePath, false, null));
assertThat(transport).isNull(); assertThat(transport).isNull();
} }
@Test @Test
void createIfPossibleWhenDockerHostInEnvironmentIsAddressReturnsTransport() { void createIfPossibleWhenDockerHostInEnvironmentIsAddressReturnsTransport() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376"); this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
this.dockerConfiguration);
assertThat(transport).isNotNull(); assertThat(transport).isNotNull();
} }
@Test @Test
void createIfPossibleWhenDockerHostInConfigurationIsAddressReturnsTransport() { void createIfPossibleWhenDockerHostInConfigurationIsAddressReturnsTransport() {
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, 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(); assertThat(transport).isNotNull();
} }
...@@ -100,8 +99,8 @@ class RemoteHttpClientTransportTests { ...@@ -100,8 +99,8 @@ class RemoteHttpClientTransportTests {
void createIfPossibleWhenTlsVerifyInEnvironmentWithMissingCertPathThrowsException() { void createIfPossibleWhenTlsVerifyInEnvironmentWithMissingCertPathThrowsException() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376"); this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
this.environment.put("DOCKER_TLS_VERIFY", "1"); this.environment.put("DOCKER_TLS_VERIFY", "1");
assertThatIllegalArgumentException().isThrownBy( assertThatIllegalArgumentException()
() -> RemoteHttpClientTransport.createIfPossible(this.environment::get, this.dockerConfiguration)) .isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(this.environment::get, null))
.withMessageContaining("Docker host TLS verification requires trust material"); .withMessageContaining("Docker host TLS verification requires trust material");
} }
...@@ -109,15 +108,14 @@ class RemoteHttpClientTransportTests { ...@@ -109,15 +108,14 @@ class RemoteHttpClientTransportTests {
void createIfPossibleWhenTlsVerifyInConfigurationWithMissingCertPathThrowsException() { void createIfPossibleWhenTlsVerifyInConfigurationWithMissingCertPathThrowsException() {
assertThatIllegalArgumentException() assertThatIllegalArgumentException()
.isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(this.environment::get, .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"); .withMessageContaining("Docker host TLS verification requires trust material");
} }
@Test @Test
void createIfPossibleWhenNoTlsVerifyUsesHttp() { void createIfPossibleWhenNoTlsVerifyUsesHttp() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376"); this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
this.dockerConfiguration);
assertThat(transport.getHost()).satisfies(hostOf("http", "192.168.1.2", 2376)); assertThat(transport.getHost()).satisfies(hostOf("http", "192.168.1.2", 2376));
} }
...@@ -129,7 +127,7 @@ class RemoteHttpClientTransportTests { ...@@ -129,7 +127,7 @@ class RemoteHttpClientTransportTests {
SslContextFactory sslContextFactory = mock(SslContextFactory.class); SslContextFactory sslContextFactory = mock(SslContextFactory.class);
given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault()); given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault());
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, 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)); assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376));
} }
...@@ -138,20 +136,11 @@ class RemoteHttpClientTransportTests { ...@@ -138,20 +136,11 @@ class RemoteHttpClientTransportTests {
SslContextFactory sslContextFactory = mock(SslContextFactory.class); SslContextFactory sslContextFactory = mock(SslContextFactory.class);
given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault()); given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault());
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, 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); sslContextFactory);
assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376)); 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) { private Consumer<HttpHost> hostOf(String scheme, String hostName, int port) {
return (host) -> { return (host) -> {
assertThat(host).isNotNull(); assertThat(host).isNotNull();
......
{
"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"}
\ No newline at end of file
{
"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}}
\ No newline at end of file
...@@ -41,6 +41,7 @@ dependencies { ...@@ -41,6 +41,7 @@ dependencies {
testImplementation("org.assertj:assertj-core") testImplementation("org.assertj:assertj-core")
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core") testImplementation("org.mockito:mockito-core")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:testcontainers") testImplementation("org.testcontainers:testcontainers")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
......
...@@ -56,11 +56,14 @@ For more details, see also <<build-image-example-docker,examples>>. ...@@ -56,11 +56,14 @@ For more details, see also <<build-image-example-docker,examples>>.
[[build-image-docker-registry]] [[build-image-docker-registry]]
=== Docker Registry === Docker Registry
If the Docker images specified by the `builder` or `runImage` parameters are stored in a private Docker image registry that requires authentication, the authentication credentials can be provided using `docker.registry` properties. If the Docker images specified by the `builder` or `runImage` properties are stored in a private Docker image registry that requires authentication, the authentication credentials can be provided using `docker.builderRegistry` properties.
If the generated Docker image is to be published to a Docker image registry, the authentication credentials can be provided using `docker.publishRegistry` properties.
Properties are provided for user authentication or identity token authentication. Properties are provided for user authentication or identity token authentication.
Consult the documentation for the Docker registry being used to store builder or run images for further information on supported authentication methods. Consult the documentation for the Docker registry being used to store images for further information on supported authentication methods.
The following table summarizes the available properties: The following table summarizes the available properties for `docker.builderRegistry` and `docker.publishRegistry`:
|=== |===
| Property | Description | Property | Description
...@@ -133,6 +136,11 @@ Acceptable values are `ALWAYS`, `NEVER`, and `IF_NOT_PRESENT`. ...@@ -133,6 +136,11 @@ Acceptable values are `ALWAYS`, `NEVER`, and `IF_NOT_PRESENT`.
| |
| Enables verbose logging of builder operations. | Enables verbose logging of builder operations.
| `false` | `false`
| `publish`
| `--publishImage`
| Whether to publish the generated image to a Docker registry.
| `false`
|=== |===
NOTE: The plugin detects the target Java compatibility of the project using the JavaPlugin's `targetCompatibility` property. NOTE: The plugin detects the target Java compatibility of the project using the JavaPlugin's `targetCompatibility` property.
...@@ -236,6 +244,29 @@ The image name can be specified on the command line as well, as shown in this ex ...@@ -236,6 +244,29 @@ The image name can be specified on the command line as well, as shown in this ex
$ gradle bootBuildImage --imageName=example.com/library/my-app:v1 $ gradle bootBuildImage --imageName=example.com/library/my-app:v1
---- ----
[[build-image-example-publish]]
==== Image Publishing
The generated image can be published to a Docker registry by enabling a `publish` option and configuring authentication for the registry using `docker.publishRegistry` properties.
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
.Groovy
----
include::../gradle/packaging/boot-build-image-publish.gradle[tags=publish]
----
[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
.Kotlin
----
include::../gradle/packaging/boot-build-image-publish.gradle.kts[tags=publish]
----
The publish option can be specified on the command line as well, as shown in this example:
[indent=0]
----
$ gradle bootBuildImage --imageName=docker.example.com/library/my-app:v1 --publishImage
----
[[build-image-example-docker]] [[build-image-example-docker]]
==== Docker Configuration ==== Docker Configuration
If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` properties as shown in the following example: If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` properties as shown in the following example:
...@@ -252,7 +283,7 @@ include::../gradle/packaging/boot-build-image-docker-host.gradle[tags=docker-hos ...@@ -252,7 +283,7 @@ include::../gradle/packaging/boot-build-image-docker-host.gradle[tags=docker-hos
include::../gradle/packaging/boot-build-image-docker-host.gradle.kts[tags=docker-host] include::../gradle/packaging/boot-build-image-docker-host.gradle.kts[tags=docker-host]
---- ----
If the builder or run image are stored in a private Docker registry that supports user authentication, authentication details can be provided using `docker.registry` properties as shown in the following example: If the builder or run image are stored in a private Docker registry that supports user authentication, authentication details can be provided using `docker.buiderRegistry` properties as shown in the following example:
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"] [source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
.Groovy .Groovy
...@@ -266,7 +297,7 @@ include::../gradle/packaging/boot-build-image-docker-auth-user.gradle[tags=docke ...@@ -266,7 +297,7 @@ include::../gradle/packaging/boot-build-image-docker-auth-user.gradle[tags=docke
include::../gradle/packaging/boot-build-image-docker-auth-user.gradle.kts[tags=docker-auth-user] include::../gradle/packaging/boot-build-image-docker-auth-user.gradle.kts[tags=docker-auth-user]
---- ----
If the builder or run image is stored in a private Docker registry that supports token authentication, the token value can be provided using `docker.registry` as shown in the following example: If the builder or run image is stored in a private Docker registry that supports token authentication, the token value can be provided using `docker.builderRegistry` as shown in the following example:
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"] [source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
.Groovy .Groovy
......
...@@ -10,7 +10,7 @@ bootJar { ...@@ -10,7 +10,7 @@ bootJar {
// tag::docker-auth-token[] // tag::docker-auth-token[]
bootBuildImage { bootBuildImage {
docker { docker {
registry { builderRegistry {
token = "9cbaf023786cd7..." token = "9cbaf023786cd7..."
} }
} }
......
...@@ -13,7 +13,7 @@ tasks.getByName<BootJar>("bootJar") { ...@@ -13,7 +13,7 @@ tasks.getByName<BootJar>("bootJar") {
// tag::docker-auth-token[] // tag::docker-auth-token[]
tasks.getByName<BootBuildImage>("bootBuildImage") { tasks.getByName<BootBuildImage>("bootBuildImage") {
docker { docker {
registry { builderRegistry {
token = "9cbaf023786cd7..." token = "9cbaf023786cd7..."
} }
} }
......
...@@ -10,7 +10,7 @@ bootJar { ...@@ -10,7 +10,7 @@ bootJar {
// tag::docker-auth-user[] // tag::docker-auth-user[]
bootBuildImage { bootBuildImage {
docker { docker {
registry { builderRegistry {
username = "user" username = "user"
password = "secret" password = "secret"
url = "https://docker.example.com/v1/" url = "https://docker.example.com/v1/"
......
...@@ -13,7 +13,7 @@ tasks.getByName<BootJar>("bootJar") { ...@@ -13,7 +13,7 @@ tasks.getByName<BootJar>("bootJar") {
// tag::docker-auth-user[] // tag::docker-auth-user[]
tasks.getByName<BootBuildImage>("bootBuildImage") { tasks.getByName<BootBuildImage>("bootBuildImage") {
docker { docker {
registry { builderRegistry {
username = "user" username = "user"
password = "secret" password = "secret"
url = "https://docker.example.com/v1/" url = "https://docker.example.com/v1/"
......
plugins {
id 'java'
id 'org.springframework.boot' version '{gradle-project-version}'
}
bootJar {
mainClassName 'com.example.ExampleApplication'
}
// tag::publish[]
bootBuildImage {
imageName = "docker.example.com/library/${project.name}"
publish = true
docker {
publishRegistry {
username = "user"
password = "secret"
url = "https://docker.example.com/v1/"
email = "user@example.com"
}
}
}
// end::publish[]
import org.springframework.boot.gradle.tasks.bundling.BootJar
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
plugins {
java
id("org.springframework.boot") version "{gradle-project-version}"
}
tasks.getByName<BootJar>("bootJar") {
mainClassName = "com.example.ExampleApplication"
}
// tag::publish[]
tasks.getByName<BootBuildImage>("bootBuildImage") {
imageName = "docker.example.com/library/${project.name}"
publish = true
docker {
publishRegistry {
username = "user"
password = "secret"
url = "https://docker.example.com/v1/"
email = "user@example.com"
}
}
}
// end::publish[]
...@@ -23,6 +23,7 @@ import java.util.Map; ...@@ -23,6 +23,7 @@ import java.util.Map;
import groovy.lang.Closure; import groovy.lang.Closure;
import org.gradle.api.Action; import org.gradle.api.Action;
import org.gradle.api.DefaultTask; import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.JavaVersion; import org.gradle.api.JavaVersion;
import org.gradle.api.Project; import org.gradle.api.Project;
import org.gradle.api.Task; import org.gradle.api.Task;
...@@ -76,6 +77,8 @@ public class BootBuildImage extends DefaultTask { ...@@ -76,6 +77,8 @@ public class BootBuildImage extends DefaultTask {
private PullPolicy pullPolicy; private PullPolicy pullPolicy;
private boolean publish;
private DockerSpec docker = new DockerSpec(); private DockerSpec docker = new DockerSpec();
public BootBuildImage() { public BootBuildImage() {
...@@ -252,6 +255,24 @@ public class BootBuildImage extends DefaultTask { ...@@ -252,6 +255,24 @@ public class BootBuildImage extends DefaultTask {
this.pullPolicy = pullPolicy; this.pullPolicy = pullPolicy;
} }
/**
* Whether the built image should be pushed to a registry.
* @return whether the built image should be pushed
*/
@Input
public boolean isPublish() {
return this.publish;
}
/**
* Sets whether the built image should be pushed to a registry.
* @param publish {@code true} the push the built image to a registry. {@code false}.
*/
@Option(option = "publishImage", description = "Publish the built image to a registry")
public void setPublish(boolean publish) {
this.publish = publish;
}
/** /**
* Returns the Docker configuration the builder will use. * Returns the Docker configuration the builder will use.
* @return docker configuration. * @return docker configuration.
...@@ -312,6 +333,7 @@ public class BootBuildImage extends DefaultTask { ...@@ -312,6 +333,7 @@ public class BootBuildImage extends DefaultTask {
request = request.withCleanCache(this.cleanCache); request = request.withCleanCache(this.cleanCache);
request = request.withVerboseLogging(this.verboseLogging); request = request.withVerboseLogging(this.verboseLogging);
request = customizePullPolicy(request); request = customizePullPolicy(request);
request = customizePublish(request);
return request; return request;
} }
...@@ -354,6 +376,16 @@ public class BootBuildImage extends DefaultTask { ...@@ -354,6 +376,16 @@ public class BootBuildImage extends DefaultTask {
return request; return request;
} }
private BuildRequest customizePublish(BuildRequest request) {
boolean publishRegistryAuthNotConfigured = this.docker == null || this.docker.getPublishRegistry() == null
|| this.docker.getPublishRegistry().hasEmptyAuth();
if (this.publish && publishRegistryAuthNotConfigured) {
throw new GradleException("Publishing an image requires docker.publishRegistry to be configured");
}
request = request.withPublish(this.publish);
return request;
}
private String translateTargetJavaVersion() { private String translateTargetJavaVersion() {
return this.targetJavaVersion.get().getMajorVersion() + ".*"; return this.targetJavaVersion.get().getMajorVersion() + ".*";
} }
......
...@@ -41,14 +41,18 @@ public class DockerSpec { ...@@ -41,14 +41,18 @@ public class DockerSpec {
private String certPath; private String certPath;
private final DockerRegistrySpec registry; private final DockerRegistrySpec builderRegistry;
private final DockerRegistrySpec publishRegistry;
public DockerSpec() { public DockerSpec() {
this.registry = new DockerRegistrySpec(); this.builderRegistry = new DockerRegistrySpec();
this.publishRegistry = new DockerRegistrySpec();
} }
DockerSpec(DockerRegistrySpec registry) { DockerSpec(DockerRegistrySpec builderRegistry, DockerRegistrySpec publishRegistry) {
this.registry = registry; this.builderRegistry = builderRegistry;
this.publishRegistry = publishRegistry;
} }
@Input @Input
...@@ -82,28 +86,59 @@ public class DockerSpec { ...@@ -82,28 +86,59 @@ public class DockerSpec {
} }
/** /**
* Returns the {@link DockerRegistrySpec} that configures registry authentication. * Returns the {@link DockerRegistrySpec} that configures authentication to the
* builder registry.
* @return the registry spec
*/
@Nested
public DockerRegistrySpec getBuilderRegistry() {
return this.builderRegistry;
}
/**
* Customizes the {@link DockerRegistrySpec} that configures authentication to the
* builder registry.
* @param action the action to apply
*/
public void builderRegistry(Action<DockerRegistrySpec> action) {
action.execute(this.builderRegistry);
}
/**
* Customizes the {@link DockerRegistrySpec} that configures authentication to the
* builder registry.
* @param closure the closure to apply
*/
public void builderRegistry(Closure<?> closure) {
builderRegistry(ConfigureUtil.configureUsing(closure));
}
/**
* Returns the {@link DockerRegistrySpec} that configures authentication to the
* publishing registry.
* @return the registry spec * @return the registry spec
*/ */
@Nested @Nested
public DockerRegistrySpec getRegistry() { public DockerRegistrySpec getPublishRegistry() {
return this.registry; return this.publishRegistry;
} }
/** /**
* Customizes the {@link DockerRegistrySpec} that configures registry authentication. * Customizes the {@link DockerRegistrySpec} that configures authentication to the
* publishing registry.
* @param action the action to apply * @param action the action to apply
*/ */
public void registry(Action<DockerRegistrySpec> action) { public void publishRegistry(Action<DockerRegistrySpec> action) {
action.execute(this.registry); action.execute(this.publishRegistry);
} }
/** /**
* Customizes the {@link DockerRegistrySpec} that configures registry authentication. * Customizes the {@link DockerRegistrySpec} that configures authentication to the
* publishing registry.
* @param closure the closure to apply * @param closure the closure to apply
*/ */
public void registry(Closure<?> closure) { public void publishRegistry(Closure<?> closure) {
registry(ConfigureUtil.configureUsing(closure)); publishRegistry(ConfigureUtil.configureUsing(closure));
} }
/** /**
...@@ -115,7 +150,8 @@ public class DockerSpec { ...@@ -115,7 +150,8 @@ public class DockerSpec {
DockerConfiguration asDockerConfiguration() { DockerConfiguration asDockerConfiguration() {
DockerConfiguration dockerConfiguration = new DockerConfiguration(); DockerConfiguration dockerConfiguration = new DockerConfiguration();
dockerConfiguration = customizeHost(dockerConfiguration); dockerConfiguration = customizeHost(dockerConfiguration);
dockerConfiguration = customizeAuthentication(dockerConfiguration); dockerConfiguration = customizeBuilderAuthentication(dockerConfiguration);
dockerConfiguration = customizePublishAuthentication(dockerConfiguration);
return dockerConfiguration; return dockerConfiguration;
} }
...@@ -126,19 +162,34 @@ public class DockerSpec { ...@@ -126,19 +162,34 @@ public class DockerSpec {
return dockerConfiguration; return dockerConfiguration;
} }
private DockerConfiguration customizeAuthentication(DockerConfiguration dockerConfiguration) { private DockerConfiguration customizeBuilderAuthentication(DockerConfiguration dockerConfiguration) {
if (this.registry == null || this.registry.hasEmptyAuth()) { if (this.builderRegistry == null || this.builderRegistry.hasEmptyAuth()) {
return dockerConfiguration; return dockerConfiguration;
} }
if (this.registry.hasTokenAuth() && !this.registry.hasUserAuth()) { if (this.builderRegistry.hasTokenAuth() && !this.builderRegistry.hasUserAuth()) {
return dockerConfiguration.withRegistryTokenAuthentication(this.registry.getToken()); return dockerConfiguration.withBuilderRegistryTokenAuthentication(this.builderRegistry.getToken());
} }
if (this.registry.hasUserAuth() && !this.registry.hasTokenAuth()) { if (this.builderRegistry.hasUserAuth() && !this.builderRegistry.hasTokenAuth()) {
return dockerConfiguration.withRegistryUserAuthentication(this.registry.getUsername(), return dockerConfiguration.withBuilderRegistryUserAuthentication(this.builderRegistry.getUsername(),
this.registry.getPassword(), this.registry.getUrl(), this.registry.getEmail()); this.builderRegistry.getPassword(), this.builderRegistry.getUrl(), this.builderRegistry.getEmail());
} }
throw new GradleException( throw new GradleException(
"Invalid Docker registry configuration, either token or username/password must be provided"); "Invalid Docker builder registry configuration, either token or username/password must be provided");
}
private DockerConfiguration customizePublishAuthentication(DockerConfiguration dockerConfiguration) {
if (this.publishRegistry == null || this.publishRegistry.hasEmptyAuth()) {
return dockerConfiguration;
}
if (this.publishRegistry.hasTokenAuth() && !this.publishRegistry.hasUserAuth()) {
return dockerConfiguration.withPublishRegistryTokenAuthentication(this.publishRegistry.getToken());
}
if (this.publishRegistry.hasUserAuth() && !this.publishRegistry.hasTokenAuth()) {
return dockerConfiguration.withPublishRegistryUserAuthentication(this.publishRegistry.getUsername(),
this.publishRegistry.getPassword(), this.publishRegistry.getUrl(), this.publishRegistry.getEmail());
}
throw new GradleException(
"Invalid Docker publish registry configuration, either token or username/password must be provided");
} }
/** /**
...@@ -156,6 +207,20 @@ public class DockerSpec { ...@@ -156,6 +207,20 @@ public class DockerSpec {
private String token; private String token;
public DockerRegistrySpec() {
}
DockerRegistrySpec(String username, String password, String url, String email) {
this.username = username;
this.password = password;
this.url = url;
this.email = email;
}
DockerRegistrySpec(String token) {
this.token = token;
}
/** /**
* Returns the username to use when authenticating to the Docker registry. * Returns the username to use when authenticating to the Docker registry.
* @return the registry username * @return the registry username
......
...@@ -178,6 +178,15 @@ class BootBuildImageIntegrationTests { ...@@ -178,6 +178,15 @@ class BootBuildImageIntegrationTests {
.containsPattern("example/Invalid-Image-Name"); .containsPattern("example/Invalid-Image-Name");
} }
@TestTemplate
void failsWithPublishMissingPublishRegistry() {
writeMainClass();
writeLongNameResource();
BuildResult result = this.gradleBuild.buildAndFail("bootBuildImage", "--publishImage");
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.FAILED);
assertThat(result.getOutput()).contains("requires docker.publishRegistry");
}
private void writeMainClass() { private void writeMainClass() {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example"); File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs(); examplePackage.mkdirs();
......
/*
* 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.gradle.tasks.bundling;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
import org.gradle.testkit.runner.BuildResult;
import org.gradle.testkit.runner.TaskOutcome;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.boot.buildpack.platform.docker.DockerApi;
import org.springframework.boot.buildpack.platform.docker.UpdateListener;
import org.springframework.boot.buildpack.platform.docker.type.Image;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import org.springframework.boot.gradle.junit.GradleCompatibility;
import org.springframework.boot.gradle.testkit.GradleBuild;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link BootBuildImage} tasks requiring a Docker image registry.
*
* @author Scott Frederick
*/
@GradleCompatibility
@Testcontainers(disabledWithoutDocker = true)
public class BootBuildImageRegistryIntegrationTests {
@Container
static final RegistryContainer registry = new RegistryContainer().withStartupAttempts(5)
.withStartupTimeout(Duration.ofMinutes(3));
String registryAddress;
GradleBuild gradleBuild;
@BeforeEach
void setUp() {
assertThat(registry.isRunning());
this.registryAddress = registry.getHost() + ":" + registry.getFirstMappedPort();
}
@TestTemplate
void buildsImageAndPublishesToRegistry() throws IOException, InterruptedException {
writeMainClass();
String repoName = "test-image";
String imageName = this.registryAddress + "/" + repoName;
BuildResult result = this.gradleBuild.build("bootBuildImage", "--imageName=" + imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Building image").contains("Successfully built image")
.contains("Pushing image '" + imageName + ":latest" + "'")
.contains("Pushed image '" + imageName + ":latest" + "'");
ImageReference imageReference = ImageReference.of(imageName);
Image pulledImage = new DockerApi().image().pull(imageReference, UpdateListener.none());
assertThat(pulledImage).isNotNull();
new DockerApi().image().remove(imageReference, false);
}
private void writeMainClass() {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs();
File main = new File(examplePackage, "Main.java");
try (PrintWriter writer = new PrintWriter(new FileWriter(main))) {
writer.println("package example;");
writer.println();
writer.println("import java.io.IOException;");
writer.println();
writer.println("public class Main {");
writer.println();
writer.println(" public static void main(String[] args) {");
writer.println(" }");
writer.println();
writer.println("}");
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
private static class RegistryContainer extends GenericContainer<RegistryContainer> {
RegistryContainer() {
super("registry:2.7.1");
addExposedPorts(5000);
addEnv("SERVER_NAME", "localhost");
}
}
}
...@@ -20,6 +20,7 @@ import java.io.File; ...@@ -20,6 +20,7 @@ import java.io.File;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.gradle.api.GradleException;
import org.gradle.api.JavaVersion; import org.gradle.api.JavaVersion;
import org.gradle.api.Project; import org.gradle.api.Project;
import org.gradle.testfixtures.ProjectBuilder; import org.gradle.testfixtures.ProjectBuilder;
...@@ -30,6 +31,7 @@ import org.springframework.boot.buildpack.platform.build.BuildRequest; ...@@ -30,6 +31,7 @@ import org.springframework.boot.buildpack.platform.build.BuildRequest;
import org.springframework.boot.buildpack.platform.build.PullPolicy; import org.springframework.boot.buildpack.platform.build.PullPolicy;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/** /**
* Tests for {@link BootBuildImage}. * Tests for {@link BootBuildImage}.
...@@ -174,6 +176,18 @@ class BootBuildImageTests { ...@@ -174,6 +176,18 @@ class BootBuildImageTests {
assertThat(this.buildImage.createRequest().isCleanCache()).isTrue(); assertThat(this.buildImage.createRequest().isCleanCache()).isTrue();
} }
@Test
void whenUsingDefaultConfigurationThenRequestHasPublishDisabled() {
assertThat(this.buildImage.createRequest().isPublish()).isFalse();
}
@Test
void whenPublishIsEnabledWithoutPublishRegistryThenExceptionIsThrown() {
this.buildImage.setPublish(true);
assertThatExceptionOfType(GradleException.class).isThrownBy(this.buildImage::createRequest)
.withMessageContaining("Publishing an image requires docker.publishRegistry to be configured");
}
@Test @Test
void whenNoBuilderIsConfiguredThenRequestHasDefaultBuilder() { void whenNoBuilderIsConfiguredThenRequestHasDefaultBuilder() {
assertThat(this.buildImage.createRequest().getBuilder().getName()).isEqualTo("paketo-buildpacks/builder"); assertThat(this.buildImage.createRequest().getBuilder().getName()).isEqualTo("paketo-buildpacks/builder");
......
...@@ -21,7 +21,6 @@ import org.junit.jupiter.api.Test; ...@@ -21,7 +21,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; 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.DockerHost;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.util.Base64Utils; import org.springframework.util.Base64Utils;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
...@@ -39,7 +38,8 @@ public class DockerSpecTests { ...@@ -39,7 +38,8 @@ public class DockerSpecTests {
void asDockerConfigurationWithDefaults() { void asDockerConfigurationWithDefaults() {
DockerSpec dockerSpec = new DockerSpec(); DockerSpec dockerSpec = new DockerSpec();
assertThat(dockerSpec.asDockerConfiguration().getHost()).isNull(); assertThat(dockerSpec.asDockerConfiguration().getHost()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getRegistryAuthentication()).isNull(); assertThat(dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
} }
@Test @Test
...@@ -53,7 +53,8 @@ public class DockerSpecTests { ...@@ -53,7 +53,8 @@ public class DockerSpecTests {
assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.getAddress()).isEqualTo("docker.example.com");
assertThat(host.isSecure()).isEqualTo(true); assertThat(host.isSecure()).isEqualTo(true);
assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert");
assertThat(dockerSpec.asDockerConfiguration().getRegistryAuthentication()).isNull(); assertThat(dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
} }
@Test @Test
...@@ -65,59 +66,71 @@ public class DockerSpecTests { ...@@ -65,59 +66,71 @@ public class DockerSpecTests {
assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.getAddress()).isEqualTo("docker.example.com");
assertThat(host.isSecure()).isEqualTo(false); assertThat(host.isSecure()).isEqualTo(false);
assertThat(host.getCertificatePath()).isNull(); assertThat(host.getCertificatePath()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getRegistryAuthentication()).isNull(); assertThat(dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
} }
@Test @Test
void asDockerConfigurationWithUserAuth() { void asDockerConfigurationWithUserAuth() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec(); DockerSpec dockerSpec = new DockerSpec(
dockerRegistry.setUsername("user"); new DockerSpec.DockerRegistrySpec("user1", "secret1", "https://docker1.example.com",
dockerRegistry.setPassword("secret"); "docker1@example.com"),
dockerRegistry.setUrl("https://docker.example.com"); new DockerSpec.DockerRegistrySpec("user2", "secret2", "https://docker2.example.com",
dockerRegistry.setEmail("docker@example.com"); "docker2@example.com"));
DockerSpec dockerSpec = new DockerSpec(dockerRegistry);
DockerConfiguration dockerConfiguration = dockerSpec.asDockerConfiguration(); DockerConfiguration dockerConfiguration = dockerSpec.asDockerConfiguration();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication(); assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
assertThat(registryAuthentication).isNotNull(); .contains("\"username\" : \"user1\"").contains("\"password\" : \"secret1\"")
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader()))) .contains("\"email\" : \"docker1@example.com\"")
.contains("\"username\" : \"user\"").contains("\"password\" : \"secret\"") .contains("\"serveraddress\" : \"https://docker1.example.com\"");
.contains("\"email\" : \"docker@example.com\"") assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"serveraddress\" : \"https://docker.example.com\""); .contains("\"username\" : \"user2\"").contains("\"password\" : \"secret2\"")
.contains("\"email\" : \"docker2@example.com\"")
.contains("\"serveraddress\" : \"https://docker2.example.com\"");
assertThat(dockerSpec.asDockerConfiguration().getHost()).isNull(); assertThat(dockerSpec.asDockerConfiguration().getHost()).isNull();
} }
@Test @Test
void asDockerConfigurationWithIncompleteUserAuthFails() { void asDockerConfigurationWithIncompleteBuilderUserAuthFails() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec(); DockerSpec.DockerRegistrySpec builderRegistry = new DockerSpec.DockerRegistrySpec("user", null,
dockerRegistry.setUsername("user"); "https://docker.example.com", "docker@example.com");
dockerRegistry.setUrl("https://docker.example.com"); DockerSpec dockerSpec = new DockerSpec(builderRegistry, null);
dockerRegistry.setEmail("docker@example.com");
DockerSpec dockerSpec = new DockerSpec(dockerRegistry);
assertThatExceptionOfType(GradleException.class).isThrownBy(dockerSpec::asDockerConfiguration) assertThatExceptionOfType(GradleException.class).isThrownBy(dockerSpec::asDockerConfiguration)
.withMessageContaining("Invalid Docker registry configuration"); .withMessageContaining("Invalid Docker builder registry configuration");
}
@Test
void asDockerConfigurationWithIncompletePublishUserAuthFails() {
DockerSpec.DockerRegistrySpec publishRegistry = new DockerSpec.DockerRegistrySpec("user2", null,
"https://docker2.example.com", "docker2@example.com");
DockerSpec dockerSpec = new DockerSpec(null, publishRegistry);
assertThatExceptionOfType(GradleException.class).isThrownBy(dockerSpec::asDockerConfiguration)
.withMessageContaining("Invalid Docker publish registry configuration");
} }
@Test @Test
void asDockerConfigurationWithTokenAuth() { void asDockerConfigurationWithTokenAuth() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec(); DockerSpec dockerSpec = new DockerSpec(new DockerSpec.DockerRegistrySpec("token1"),
dockerRegistry.setToken("token"); new DockerSpec.DockerRegistrySpec("token2"));
DockerSpec dockerSpec = new DockerSpec(dockerRegistry);
DockerConfiguration dockerConfiguration = dockerSpec.asDockerConfiguration(); DockerConfiguration dockerConfiguration = dockerSpec.asDockerConfiguration();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication(); assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
assertThat(registryAuthentication).isNotNull(); .contains("\"identitytoken\" : \"token1\"");
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader()))) assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"identitytoken\" : \"token\""); .contains("\"identitytoken\" : \"token2\"");
} }
@Test @Test
void asDockerConfigurationWithUserAndTokenAuthFails() { void asDockerConfigurationWithUserAndTokenAuthFails() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec(); DockerSpec.DockerRegistrySpec builderRegistry = new DockerSpec.DockerRegistrySpec();
dockerRegistry.setUsername("user"); builderRegistry.setUsername("user");
dockerRegistry.setPassword("secret"); builderRegistry.setPassword("secret");
dockerRegistry.setToken("token"); builderRegistry.setToken("token");
DockerSpec dockerSpec = new DockerSpec(dockerRegistry); DockerSpec dockerSpec = new DockerSpec(builderRegistry, null);
assertThatExceptionOfType(GradleException.class).isThrownBy(dockerSpec::asDockerConfiguration) assertThatExceptionOfType(GradleException.class).isThrownBy(dockerSpec::asDockerConfiguration)
.withMessageContaining("Invalid Docker registry configuration"); .withMessageContaining("Invalid Docker builder registry configuration");
}
String decoded(String value) {
return new String(Base64Utils.decodeFromString(value));
} }
} }
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
bootBuildImage {
publish = true
docker {
publishRegistry {
username = "user"
password = "secret"
}
}
}
\ No newline at end of file
...@@ -28,6 +28,7 @@ dependencies { ...@@ -28,6 +28,7 @@ dependencies {
intTestImplementation("org.assertj:assertj-core") intTestImplementation("org.assertj:assertj-core")
intTestImplementation("org.junit.jupiter:junit-jupiter") intTestImplementation("org.junit.jupiter:junit-jupiter")
intTestImplementation("org.testcontainers:testcontainers") intTestImplementation("org.testcontainers:testcontainers")
intTestImplementation("org.testcontainers:junit-jupiter")
optional("org.apache.maven.plugins:maven-shade-plugin") optional("org.apache.maven.plugins:maven-shade-plugin")
......
...@@ -79,11 +79,14 @@ For more details, see also <<build-image-example-docker,examples>>. ...@@ -79,11 +79,14 @@ For more details, see also <<build-image-example-docker,examples>>.
[[build-image-docker-registry]] [[build-image-docker-registry]]
=== Docker Registry === Docker Registry
If the Docker images specified by the `builder` or `runImage` parameters are stored in a private Docker image registry that requires authentication, the authentication credentials can be provided using `docker.registry` parameters. If the Docker images specified by the `builder` or `runImage` parameters are stored in a private Docker image registry that requires authentication, the authentication credentials can be provided using `docker.builderRegistry` parameters.
If the generated Docker image is to be published to a Docker image registry, the authentication credentials can be provided using `docker.publishRegistry` parameters.
Parameters are provided for user authentication or identity token authentication. Parameters are provided for user authentication or identity token authentication.
Consult the documentation for the Docker registry being used to store builder or run images for further information on supported authentication methods. Consult the documentation for the Docker registry being used to store images for further information on supported authentication methods.
The following table summarizes the available parameters: The following table summarizes the available parameters for `docker.builderRegistry` and `docker.publishRegistry`:
|=== |===
| Parameter | Description | Parameter | Description
...@@ -156,6 +159,11 @@ Acceptable values are `ALWAYS`, `NEVER`, and `IF_NOT_PRESENT`. ...@@ -156,6 +159,11 @@ Acceptable values are `ALWAYS`, `NEVER`, and `IF_NOT_PRESENT`.
| Enables verbose logging of builder operations. | Enables verbose logging of builder operations.
| |
| `false` | `false`
| `publish`
| Whether to publish the generated image to a Docker registry.
| `spring-boot.build-image.publish`
| `false`
|=== |===
NOTE: The plugin detects the target Java compatibility of the project using the compiler's plugin configuration or the `maven.compiler.target` property. NOTE: The plugin detects the target Java compatibility of the project using the compiler's plugin configuration or the `maven.compiler.target` property.
...@@ -303,6 +311,48 @@ The image name can be specified on the command line as well, as shown in this ex ...@@ -303,6 +311,48 @@ The image name can be specified on the command line as well, as shown in this ex
[[build-image-example-publish]]
==== Image Publishing
The generated image can be published to a Docker registry by enabling a `publish` option and configuring authentication for the registry using `docker.publishRegistry` parameters.
[source,xml,indent=0,subs="verbatim,attributes"]
----
<project>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>{gradle-project-version}</version>
<configuration>
<image>
<name>docker.example.com/library/${project.artifactId}</name>
<publish>true</publish>
</image>
<docker>
<publishRegistry>
<username>user</username>
<password>secret</password>
<url>https://docker.example.com/v1/</url>
<email>user@example.com</email>
</builderpublish>
</docker>
</configuration>
</plugin>
</plugins>
</build>
</project>
----
The `publish` option can be specified on the command line as well, as shown in this example:
[indent=0]
----
$ mvn spring-boot:build-image -Dspring-boot.build-image.imageName=docker.example.com/library/my-app:v1 -Dspring-boot.build-image.publish=true
----
[[build-image-example-docker]] [[build-image-example-docker]]
==== Docker Configuration ==== Docker Configuration
If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` parameters as shown in the following example: If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` parameters as shown in the following example:
...@@ -329,7 +379,7 @@ If you need the plugin to communicate with the Docker daemon using a remote conn ...@@ -329,7 +379,7 @@ If you need the plugin to communicate with the Docker daemon using a remote conn
</project> </project>
---- ----
If the builder or run image are stored in a private Docker registry that supports user authentication, authentication details can be provided using `docker.registry` parameters as shown in the following example: If the builder or run image are stored in a private Docker registry that supports user authentication, authentication details can be provided using `docker.builderRegistry` parameters as shown in the following example:
[source,xml,indent=0,subs="verbatim,attributes"] [source,xml,indent=0,subs="verbatim,attributes"]
---- ----
...@@ -342,12 +392,12 @@ If the builder or run image are stored in a private Docker registry that support ...@@ -342,12 +392,12 @@ If the builder or run image are stored in a private Docker registry that support
<version>{gradle-project-version}</version> <version>{gradle-project-version}</version>
<configuration> <configuration>
<docker> <docker>
<registry> <builderRegistry>
<username>user</username> <username>user</username>
<password>secret</password> <password>secret</password>
<url>https://docker.example.com/v1/</url> <url>https://docker.example.com/v1/</url>
<email>user@example.com</email> <email>user@example.com</email>
</registry> </builderRegistry>
</docker> </docker>
</configuration> </configuration>
</plugin> </plugin>
...@@ -356,7 +406,7 @@ If the builder or run image are stored in a private Docker registry that support ...@@ -356,7 +406,7 @@ If the builder or run image are stored in a private Docker registry that support
</project> </project>
---- ----
If the builder or run image is stored in a private Docker registry that supports token authentication, the token value can be provided using `docker.registry` parameters as shown in the following example: If the builder or run image is stored in a private Docker registry that supports token authentication, the token value can be provided using `docker.builderRegistry` parameters as shown in the following example:
[source,xml,indent=0,subs="verbatim,attributes"] [source,xml,indent=0,subs="verbatim,attributes"]
---- ----
...@@ -369,9 +419,9 @@ If the builder or run image is stored in a private Docker registry that supports ...@@ -369,9 +419,9 @@ If the builder or run image is stored in a private Docker registry that supports
<version>{gradle-project-version}</version> <version>{gradle-project-version}</version>
<configuration> <configuration>
<docker> <docker>
<registry> <builderRegistry>
<token>9cbaf023786cd7...</token> <token>9cbaf023786cd7...</token>
</registry> </builderRegistry>
</docker> </docker>
</configuration> </configuration>
</plugin> </plugin>
......
/*
* 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.maven;
import java.time.Duration;
import com.github.dockerjava.api.DockerClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.boot.buildpack.platform.docker.DockerApi;
import org.springframework.boot.buildpack.platform.docker.UpdateListener;
import org.springframework.boot.buildpack.platform.docker.type.Image;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for the Maven plugin's image support using a Docker image registry.
*
* @author Scott Frederick
*/
@ExtendWith(MavenBuildExtension.class)
@Testcontainers(disabledWithoutDocker = true)
public class BuildImageRegistryIntegrationTests extends AbstractArchiveIntegrationTests {
@Container
static final RegistryContainer registry = new RegistryContainer().withStartupAttempts(5)
.withStartupTimeout(Duration.ofMinutes(3));
DockerClient dockerClient;
String registryAddress;
@BeforeEach
void setUp() {
assertThat(registry.isRunning());
this.dockerClient = registry.getDockerClient();
this.registryAddress = registry.getHost() + ":" + registry.getFirstMappedPort();
}
@TestTemplate
void whenBuildImageIsInvokedWithPublish(MavenBuild mavenBuild) {
String repoName = "test-image";
String imageName = this.registryAddress + "/" + repoName;
mavenBuild.project("build-image-publish").goals("package")
.systemProperty("spring-boot.build-image.imageName", imageName).execute((project) -> {
assertThat(buildLog(project)).contains("Building image").contains("Successfully built image")
.contains("Pushing image '" + imageName + ":latest" + "'")
.contains("Pushed image '" + imageName + ":latest" + "'");
ImageReference imageReference = ImageReference.of(imageName);
DockerApi.ImageApi imageApi = new DockerApi().image();
Image pulledImage = imageApi.pull(imageReference, UpdateListener.none());
assertThat(pulledImage).isNotNull();
imageApi.remove(imageReference, false);
});
}
private static class RegistryContainer extends GenericContainer<RegistryContainer> {
RegistryContainer() {
super("registry:2.7.1");
addExposedPorts(5000);
addEnv("SERVER_NAME", "localhost");
}
}
}
...@@ -148,6 +148,12 @@ public class BuildImageTests extends AbstractArchiveIntegrationTests { ...@@ -148,6 +148,12 @@ public class BuildImageTests extends AbstractArchiveIntegrationTests {
}); });
} }
@TestTemplate
void failsWhenPublishWithoutPublishRegistryConfigured(MavenBuild mavenBuild) {
mavenBuild.project("build-image").goals("package").systemProperty("spring-boot.build-image.publish", "true")
.executeAndFail((project) -> assertThat(buildLog(project)).contains("requires docker.publishRegistry"));
}
@TestTemplate @TestTemplate
void failsWhenBuilderFails(MavenBuild mavenBuild) { void failsWhenBuilderFails(MavenBuild mavenBuild) {
mavenBuild.project("build-image-builder-error").goals("package") mavenBuild.project("build-image-builder-error").goals("package")
......
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework.boot.maven.it</groupId>
<artifactId>build-image</artifactId>
<version>0.0.1.BUILD-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>@java.version@</maven.compiler.source>
<maven.compiler.target>@java.version@</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>@project.groupId@</groupId>
<artifactId>@project.artifactId@</artifactId>
<version>@project.version@</version>
<executions>
<execution>
<goals>
<goal>build-image</goal>
</goals>
<configuration>
<image>
<publish>true</publish>
</image>
<docker>
<publishRegistry>
<username>user</username>
<password>secret</password>
</publishRegistry>
</docker>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
/*
* 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.test;
public class SampleApplication {
public static void main(String[] args) throws Exception {
System.out.println("Launched");
synchronized(args) {
args.wait(); // Prevent exit"
}
}
}
...@@ -100,8 +100,8 @@ public class BuildImageMojo extends AbstractPackagerMojo { ...@@ -100,8 +100,8 @@ public class BuildImageMojo extends AbstractPackagerMojo {
private String classifier; private String classifier;
/** /**
* Image configuration, with `builder`, `runImage`, `name`, `env`, `cleanCache` and * Image configuration, with `builder`, `runImage`, `name`, `env`, `cleanCache`,
* `verboseLogging` options. * `verboseLogging`, and `publish` options.
* @since 2.3.0 * @since 2.3.0
*/ */
@Parameter @Parameter
...@@ -136,6 +136,12 @@ public class BuildImageMojo extends AbstractPackagerMojo { ...@@ -136,6 +136,12 @@ public class BuildImageMojo extends AbstractPackagerMojo {
@Parameter(property = "spring-boot.build-image.pullPolicy", readonly = true) @Parameter(property = "spring-boot.build-image.pullPolicy", readonly = true)
PullPolicy pullPolicy; PullPolicy pullPolicy;
/**
* Alias for {@link Image#publish} to support configuration via command-line property.
*/
@Parameter(property = "spring-boot.build-image.publish", readonly = true)
Boolean publish;
/** /**
* Docker configuration options. * Docker configuration options.
* @since 2.4.0 * @since 2.4.0
...@@ -170,7 +176,7 @@ public class BuildImageMojo extends AbstractPackagerMojo { ...@@ -170,7 +176,7 @@ public class BuildImageMojo extends AbstractPackagerMojo {
} }
} }
private BuildRequest getBuildRequest(Libraries libraries) { private BuildRequest getBuildRequest(Libraries libraries) throws MojoExecutionException {
Function<Owner, TarArchive> content = (owner) -> getApplicationContent(owner, libraries); Function<Owner, TarArchive> content = (owner) -> getApplicationContent(owner, libraries);
Image image = (this.image != null) ? this.image : new Image(); Image image = (this.image != null) ? this.image : new Image();
if (image.name == null && this.imageName != null) { if (image.name == null && this.imageName != null) {
...@@ -185,9 +191,20 @@ public class BuildImageMojo extends AbstractPackagerMojo { ...@@ -185,9 +191,20 @@ public class BuildImageMojo extends AbstractPackagerMojo {
if (image.pullPolicy == null && this.pullPolicy != null) { if (image.pullPolicy == null && this.pullPolicy != null) {
image.setPullPolicy(this.pullPolicy); image.setPullPolicy(this.pullPolicy);
} }
if (image.publish == null && this.publish != null) {
image.setPublish(this.publish);
}
if (image.publish != null && image.publish && publishRegistryNotConfigured()) {
throw new MojoExecutionException("Publishing an image requires docker.publishRegistry to be configured");
}
return customize(image.getBuildRequest(this.project.getArtifact(), content)); return customize(image.getBuildRequest(this.project.getArtifact(), content));
} }
private boolean publishRegistryNotConfigured() {
return this.docker == null || this.docker.getPublishRegistry() == null
|| this.docker.getPublishRegistry().isEmpty();
}
private TarArchive getApplicationContent(Owner owner, Libraries libraries) { private TarArchive getApplicationContent(Owner owner, Libraries libraries) {
ImagePackager packager = getConfiguredPackager(() -> new ImagePackager(getJarFile())); ImagePackager packager = getConfiguredPackager(() -> new ImagePackager(getJarFile()));
return new PackagedTarArchive(owner, libraries, packager); return new PackagedTarArchive(owner, libraries, packager);
......
...@@ -33,7 +33,9 @@ public class Docker { ...@@ -33,7 +33,9 @@ public class Docker {
private String certPath; private String certPath;
private DockerRegistry registry; private DockerRegistry builderRegistry;
private DockerRegistry publishRegistry;
public String getHost() { public String getHost() {
return this.host; return this.host;
...@@ -59,12 +61,30 @@ public class Docker { ...@@ -59,12 +61,30 @@ public class Docker {
this.certPath = certPath; this.certPath = certPath;
} }
DockerRegistry getBuilderRegistry() {
return this.builderRegistry;
}
/**
* Sets the {@link DockerRegistry} that configures authentication to the builder
* registry.
* @param builderRegistry the registry configuration
*/
public void setBuilderRegistry(DockerRegistry builderRegistry) {
this.builderRegistry = builderRegistry;
}
DockerRegistry getPublishRegistry() {
return this.publishRegistry;
}
/** /**
* Sets the {@link DockerRegistry} that configures registry authentication. * Sets the {@link DockerRegistry} that configures authentication to the publishing
* @param registry the registry configuration * registry.
* @param builderRegistry the registry configuration
*/ */
public void setRegistry(DockerRegistry registry) { public void setPublishRegistry(DockerRegistry builderRegistry) {
this.registry = registry; this.publishRegistry = builderRegistry;
} }
/** /**
...@@ -76,7 +96,8 @@ public class Docker { ...@@ -76,7 +96,8 @@ public class Docker {
DockerConfiguration asDockerConfiguration() { DockerConfiguration asDockerConfiguration() {
DockerConfiguration dockerConfiguration = new DockerConfiguration(); DockerConfiguration dockerConfiguration = new DockerConfiguration();
dockerConfiguration = customizeHost(dockerConfiguration); dockerConfiguration = customizeHost(dockerConfiguration);
dockerConfiguration = customizeAuthentication(dockerConfiguration); dockerConfiguration = customizeBuilderAuthentication(dockerConfiguration);
dockerConfiguration = customizePublishAuthentication(dockerConfiguration);
return dockerConfiguration; return dockerConfiguration;
} }
...@@ -87,19 +108,34 @@ public class Docker { ...@@ -87,19 +108,34 @@ public class Docker {
return dockerConfiguration; return dockerConfiguration;
} }
private DockerConfiguration customizeAuthentication(DockerConfiguration dockerConfiguration) { private DockerConfiguration customizeBuilderAuthentication(DockerConfiguration dockerConfiguration) {
if (this.registry == null || this.registry.isEmpty()) { if (this.builderRegistry == null || this.builderRegistry.isEmpty()) {
return dockerConfiguration; return dockerConfiguration;
} }
if (this.registry.hasTokenAuth() && !this.registry.hasUserAuth()) { if (this.builderRegistry.hasTokenAuth() && !this.builderRegistry.hasUserAuth()) {
return dockerConfiguration.withRegistryTokenAuthentication(this.registry.getToken()); return dockerConfiguration.withBuilderRegistryTokenAuthentication(this.builderRegistry.getToken());
} }
if (this.registry.hasUserAuth() && !this.registry.hasTokenAuth()) { if (this.builderRegistry.hasUserAuth() && !this.builderRegistry.hasTokenAuth()) {
return dockerConfiguration.withRegistryUserAuthentication(this.registry.getUsername(), return dockerConfiguration.withBuilderRegistryUserAuthentication(this.builderRegistry.getUsername(),
this.registry.getPassword(), this.registry.getUrl(), this.registry.getEmail()); this.builderRegistry.getPassword(), this.builderRegistry.getUrl(), this.builderRegistry.getEmail());
} }
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Invalid Docker registry configuration, either token or username/password must be provided"); "Invalid Docker builder registry configuration, either token or username/password must be provided");
}
private DockerConfiguration customizePublishAuthentication(DockerConfiguration dockerConfiguration) {
if (this.publishRegistry == null || this.publishRegistry.isEmpty()) {
return dockerConfiguration;
}
if (this.publishRegistry.hasTokenAuth() && !this.publishRegistry.hasUserAuth()) {
return dockerConfiguration.withPublishRegistryTokenAuthentication(this.publishRegistry.getToken());
}
if (this.publishRegistry.hasUserAuth() && !this.publishRegistry.hasTokenAuth()) {
return dockerConfiguration.withPublishRegistryUserAuthentication(this.publishRegistry.getUsername(),
this.publishRegistry.getPassword(), this.publishRegistry.getUrl(), this.publishRegistry.getEmail());
}
throw new IllegalArgumentException(
"Invalid Docker publish registry configuration, either token or username/password must be provided");
} }
/** /**
...@@ -117,6 +153,20 @@ public class Docker { ...@@ -117,6 +153,20 @@ public class Docker {
private String token; private String token;
public DockerRegistry() {
}
public DockerRegistry(String username, String password, String url, String email) {
this.username = username;
this.password = password;
this.url = url;
this.email = email;
}
public DockerRegistry(String token) {
this.token = token;
}
String getUsername() { String getUsername() {
return this.username; return this.username;
} }
......
...@@ -73,6 +73,11 @@ public class Image { ...@@ -73,6 +73,11 @@ public class Image {
*/ */
PullPolicy pullPolicy; PullPolicy pullPolicy;
/**
* If the built image should be pushed to a registry.
*/
Boolean publish;
void setName(String name) { void setName(String name) {
this.name = name; this.name = name;
} }
...@@ -89,6 +94,10 @@ public class Image { ...@@ -89,6 +94,10 @@ public class Image {
this.pullPolicy = pullPolicy; this.pullPolicy = pullPolicy;
} }
public void setPublish(Boolean publish) {
this.publish = publish;
}
BuildRequest getBuildRequest(Artifact artifact, Function<Owner, TarArchive> applicationContent) { BuildRequest getBuildRequest(Artifact artifact, Function<Owner, TarArchive> applicationContent) {
return customize(BuildRequest.of(getOrDeduceName(artifact), applicationContent)); return customize(BuildRequest.of(getOrDeduceName(artifact), applicationContent));
} }
...@@ -116,6 +125,9 @@ public class Image { ...@@ -116,6 +125,9 @@ public class Image {
if (this.pullPolicy != null) { if (this.pullPolicy != null) {
request = request.withPullPolicy(this.pullPolicy); request = request.withPullPolicy(this.pullPolicy);
} }
if (this.publish != null) {
request = request.withPublish(this.publish);
}
return request; return request;
} }
......
...@@ -20,7 +20,6 @@ import org.junit.jupiter.api.Test; ...@@ -20,7 +20,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; 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.DockerHost;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.util.Base64Utils; import org.springframework.util.Base64Utils;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
...@@ -38,7 +37,8 @@ public class DockerTests { ...@@ -38,7 +37,8 @@ public class DockerTests {
void asDockerConfigurationWithDefaults() { void asDockerConfigurationWithDefaults() {
Docker docker = new Docker(); Docker docker = new Docker();
assertThat(docker.asDockerConfiguration().getHost()).isNull(); assertThat(docker.asDockerConfiguration().getHost()).isNull();
assertThat(docker.asDockerConfiguration().getRegistryAuthentication()).isNull(); assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(docker.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
} }
@Test @Test
...@@ -52,50 +52,56 @@ public class DockerTests { ...@@ -52,50 +52,56 @@ public class DockerTests {
assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.getAddress()).isEqualTo("docker.example.com");
assertThat(host.isSecure()).isEqualTo(true); assertThat(host.isSecure()).isEqualTo(true);
assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert");
assertThat(docker.asDockerConfiguration().getRegistryAuthentication()).isNull(); assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(docker.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
} }
@Test @Test
void asDockerConfigurationWithUserAuth() { void asDockerConfigurationWithUserAuth() {
Docker.DockerRegistry dockerRegistry = new Docker.DockerRegistry();
dockerRegistry.setUsername("user");
dockerRegistry.setPassword("secret");
dockerRegistry.setUrl("https://docker.example.com");
dockerRegistry.setEmail("docker@example.com");
Docker docker = new Docker(); Docker docker = new Docker();
docker.setRegistry(dockerRegistry); docker.setBuilderRegistry(
new Docker.DockerRegistry("user1", "secret1", "https://docker1.example.com", "docker1@example.com"));
docker.setPublishRegistry(
new Docker.DockerRegistry("user2", "secret2", "https://docker2.example.com", "docker2@example.com"));
DockerConfiguration dockerConfiguration = docker.asDockerConfiguration(); DockerConfiguration dockerConfiguration = docker.asDockerConfiguration();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication(); assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
assertThat(registryAuthentication).isNotNull(); .contains("\"username\" : \"user1\"").contains("\"password\" : \"secret1\"")
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader()))) .contains("\"email\" : \"docker1@example.com\"")
.contains("\"username\" : \"user\"").contains("\"password\" : \"secret\"") .contains("\"serveraddress\" : \"https://docker1.example.com\"");
.contains("\"email\" : \"docker@example.com\"") assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"serveraddress\" : \"https://docker.example.com\""); .contains("\"username\" : \"user2\"").contains("\"password\" : \"secret2\"")
.contains("\"email\" : \"docker2@example.com\"")
.contains("\"serveraddress\" : \"https://docker2.example.com\"");
} }
@Test @Test
void asDockerConfigurationWithIncompleteUserAuthFails() { void asDockerConfigurationWithIncompleteBuilderUserAuthFails() {
Docker.DockerRegistry dockerRegistry = new Docker.DockerRegistry(); Docker docker = new Docker();
dockerRegistry.setUsername("user"); docker.setBuilderRegistry(
dockerRegistry.setUrl("https://docker.example.com"); new Docker.DockerRegistry("user", null, "https://docker.example.com", "docker@example.com"));
dockerRegistry.setEmail("docker@example.com"); assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration)
.withMessageContaining("Invalid Docker builder registry configuration");
}
@Test
void asDockerConfigurationWithIncompletePublishUserAuthFails() {
Docker docker = new Docker(); Docker docker = new Docker();
docker.setRegistry(dockerRegistry); docker.setPublishRegistry(
new Docker.DockerRegistry("user", null, "https://docker.example.com", "docker@example.com"));
assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration) assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration)
.withMessageContaining("Invalid Docker registry configuration"); .withMessageContaining("Invalid Docker publish registry configuration");
} }
@Test @Test
void asDockerConfigurationWithTokenAuth() { void asDockerConfigurationWithTokenAuth() {
Docker.DockerRegistry dockerRegistry = new Docker.DockerRegistry();
dockerRegistry.setToken("token");
Docker docker = new Docker(); Docker docker = new Docker();
docker.setRegistry(dockerRegistry); docker.setBuilderRegistry(new Docker.DockerRegistry("token1"));
docker.setPublishRegistry(new Docker.DockerRegistry("token2"));
DockerConfiguration dockerConfiguration = docker.asDockerConfiguration(); DockerConfiguration dockerConfiguration = docker.asDockerConfiguration();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication(); assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
assertThat(registryAuthentication).isNotNull(); .contains("\"identitytoken\" : \"token1\"");
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader()))) assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"identitytoken\" : \"token\""); .contains("\"identitytoken\" : \"token2\"");
} }
@Test @Test
...@@ -105,9 +111,13 @@ public class DockerTests { ...@@ -105,9 +111,13 @@ public class DockerTests {
dockerRegistry.setPassword("secret"); dockerRegistry.setPassword("secret");
dockerRegistry.setToken("token"); dockerRegistry.setToken("token");
Docker docker = new Docker(); Docker docker = new Docker();
docker.setRegistry(dockerRegistry); docker.setBuilderRegistry(dockerRegistry);
assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration) assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration)
.withMessageContaining("Invalid Docker registry configuration"); .withMessageContaining("Invalid Docker builder registry configuration");
}
String decoded(String value) {
return new String(Base64Utils.decodeFromString(value));
} }
} }
...@@ -115,6 +115,14 @@ class ImageTests { ...@@ -115,6 +115,14 @@ class ImageTests {
assertThat(request.getPullPolicy()).isEqualTo(PullPolicy.NEVER); assertThat(request.getPullPolicy()).isEqualTo(PullPolicy.NEVER);
} }
@Test
void getBuildRequestWhenHasPublishUsesPublish() {
Image image = new Image();
image.publish = true;
BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
assertThat(request.isPublish()).isTrue();
}
private Artifact createArtifact() { private Artifact createArtifact() {
return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile", return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile",
"jar", null, new DefaultArtifactHandler()); "jar", null, new DefaultArtifactHandler());
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment