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 {
log(String.format(" > Pulled %s '%s'", imageType.getDescription(), getDigest(image)));
}
@Override
public Consumer<TotalProgressEvent> pushingImage(ImageReference imageReference) {
return getProgressConsumer(String.format(" > Pushing image '%s'", imageReference));
}
@Override
public void pushedImage(ImageReference imageReference) {
log(String.format(" > Pushed image '%s'", imageReference));
}
@Override
public void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume) {
log(" > Executing lifecycle version " + version);
......
......@@ -92,11 +92,24 @@ public interface BuildLog {
/**
* Log that an image has been pulled.
* @param image the builder image that was pulled
* @param image the image that was pulled
* @param imageType the image type that was pulled
*/
void pulledImage(Image image, ImageType imageType);
/**
* Log that an image is being pushed.
* @param imageReference the image reference
* @return a consumer for progress update events
*/
Consumer<TotalProgressEvent> pushingImage(ImageReference imageReference);
/**
* Log that an image has been pushed.
* @param imageReference the image reference
*/
void pushedImage(ImageReference imageReference);
/**
* Log that the lifecycle is executing.
* @param request the build request
......
......@@ -59,6 +59,8 @@ public class BuildRequest {
private final PullPolicy pullPolicy;
private final boolean publish;
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) {
Assert.notNull(name, "Name must not be null");
Assert.notNull(applicationContent, "ApplicationContent must not be null");
......@@ -70,12 +72,13 @@ public class BuildRequest {
this.cleanCache = false;
this.verboseLogging = false;
this.pullPolicy = PullPolicy.ALWAYS;
this.publish = false;
this.creator = Creator.withVersion("");
}
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder,
ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache,
boolean verboseLogging, PullPolicy pullPolicy) {
boolean verboseLogging, PullPolicy pullPolicy, boolean publish) {
this.name = name;
this.applicationContent = applicationContent;
this.builder = builder;
......@@ -85,6 +88,7 @@ public class BuildRequest {
this.cleanCache = cleanCache;
this.verboseLogging = verboseLogging;
this.pullPolicy = pullPolicy;
this.publish = publish;
}
/**
......@@ -95,7 +99,7 @@ public class BuildRequest {
public BuildRequest withBuilder(ImageReference builder) {
Assert.notNull(builder, "Builder must not be null");
return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage,
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy);
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
......@@ -105,7 +109,7 @@ public class BuildRequest {
*/
public BuildRequest withRunImage(ImageReference runImageName) {
return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(),
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy);
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
......@@ -116,7 +120,7 @@ public class BuildRequest {
public BuildRequest withCreator(Creator creator) {
Assert.notNull(creator, "Creator must not be null");
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy);
this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
......@@ -131,7 +135,7 @@ public class BuildRequest {
Map<String, String> env = new LinkedHashMap<>(this.env);
env.put(name, value);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy);
Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
......@@ -144,7 +148,8 @@ public class BuildRequest {
Map<String, String> updatedEnv = new LinkedHashMap<>(this.env);
updatedEnv.putAll(env);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy);
Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy,
this.publish);
}
/**
......@@ -154,7 +159,7 @@ public class BuildRequest {
*/
public BuildRequest withCleanCache(boolean cleanCache) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
cleanCache, this.verboseLogging, this.pullPolicy);
cleanCache, this.verboseLogging, this.pullPolicy, this.publish);
}
/**
......@@ -164,7 +169,7 @@ public class BuildRequest {
*/
public BuildRequest withVerboseLogging(boolean verboseLogging) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, verboseLogging, this.pullPolicy);
this.cleanCache, verboseLogging, this.pullPolicy, this.publish);
}
/**
......@@ -174,7 +179,17 @@ public class BuildRequest {
*/
public BuildRequest withPullPolicy(PullPolicy pullPolicy) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, pullPolicy);
this.cleanCache, this.verboseLogging, pullPolicy, this.publish);
}
/**
* Return a new {@link BuildRequest} with an updated publish setting.
* @param publish if the built image should be pushed to a registry
* @return an updated build request
*/
public BuildRequest withPublish(boolean publish) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy, publish);
}
/**
......@@ -244,6 +259,14 @@ public class BuildRequest {
return this.verboseLogging;
}
/**
* Return if the built image should be pushed to a registry.
* @return if the built image should be pushed to a registry
*/
public boolean isPublish() {
return this.publish;
}
/**
* Return the image {@link PullPolicy} that the builder should use.
* @return image pull policy
......
......@@ -23,6 +23,7 @@ import org.springframework.boot.buildpack.platform.build.BuilderMetadata.Stack;
import org.springframework.boot.buildpack.platform.docker.DockerApi;
import org.springframework.boot.buildpack.platform.docker.TotalProgressEvent;
import org.springframework.boot.buildpack.platform.docker.TotalProgressPullListener;
import org.springframework.boot.buildpack.platform.docker.TotalProgressPushListener;
import org.springframework.boot.buildpack.platform.docker.UpdateListener;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.transport.DockerEngineException;
......@@ -45,6 +46,8 @@ public class Builder {
private final DockerApi docker;
private final DockerConfiguration dockerConfiguration;
/**
* Create a new builder instance.
*/
......@@ -66,7 +69,7 @@ public class Builder {
* @param log a logger used to record output
*/
public Builder(BuildLog log) {
this(log, new DockerApi());
this(log, new DockerApi(), null);
}
/**
......@@ -76,13 +79,14 @@ public class Builder {
* @since 2.4.0
*/
public Builder(BuildLog log, DockerConfiguration dockerConfiguration) {
this(log, new DockerApi(dockerConfiguration));
this(log, new DockerApi(dockerConfiguration), dockerConfiguration);
}
Builder(BuildLog log, DockerApi docker) {
Builder(BuildLog log, DockerApi docker, DockerConfiguration dockerConfiguration) {
Assert.notNull(log, "Log must not be null");
this.log = log;
this.docker = docker;
this.dockerConfiguration = dockerConfiguration;
}
public void build(BuildRequest request) throws DockerEngineException, IOException {
......@@ -97,6 +101,9 @@ public class Builder {
this.docker.image().load(builder.getArchive(), UpdateListener.none());
try {
executeLifecycle(request, builder);
if (request.isPublish()) {
pushImage(request.getName());
}
}
finally {
this.docker.image().remove(builder.getName(), true);
......@@ -143,11 +150,28 @@ public class Builder {
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
Consumer<TotalProgressEvent> progressConsumer = this.log.pullingImage(reference, imageType);
TotalProgressPullListener listener = new TotalProgressPullListener(progressConsumer);
Image image = this.docker.image().pull(reference, listener);
Image image = this.docker.image().pull(reference, listener, getBuilderAuthHeader());
this.log.pulledImage(image, imageType);
return image;
}
private void pushImage(ImageReference reference) throws IOException {
Consumer<TotalProgressEvent> progressConsumer = this.log.pushingImage(reference);
TotalProgressPushListener listener = new TotalProgressPushListener(progressConsumer);
this.docker.image().push(reference, listener, getPublishAuthHeader());
this.log.pushedImage(reference);
}
private String getBuilderAuthHeader() {
return (this.dockerConfiguration != null && this.dockerConfiguration.getBuilderRegistryAuthentication() != null)
? this.dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader() : null;
}
private String getPublishAuthHeader() {
return (this.dockerConfiguration != null && this.dockerConfiguration.getPublishRegistryAuthentication() != null)
? this.dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader() : null;
}
private void assertStackIdsMatch(Image runImage, Image builderImage) {
StackId runImageStackId = StackId.fromImage(runImage);
StackId builderImageStackId = StackId.fromImage(builderImage);
......
......@@ -78,7 +78,7 @@ public class DockerApi {
* @since 2.4.0
*/
public DockerApi(DockerConfiguration dockerConfiguration) {
this(HttpTransport.create(dockerConfiguration));
this(HttpTransport.create((dockerConfiguration != null) ? dockerConfiguration.getHost() : null));
}
/**
......@@ -156,13 +156,26 @@ public class DockerApi {
* @throws IOException on IO error
*/
public Image pull(ImageReference reference, UpdateListener<PullImageUpdateEvent> listener) throws IOException {
return pull(reference, listener, null);
}
/**
* Pull an image from a registry.
* @param reference the image reference to pull
* @param listener a pull listener to receive update events
* @param registryAuth registry authentication credentials
* @return the {@link ImageApi pulled image} instance
* @throws IOException on IO error
*/
public Image pull(ImageReference reference, UpdateListener<PullImageUpdateEvent> listener, String registryAuth)
throws IOException {
Assert.notNull(reference, "Reference must not be null");
Assert.notNull(listener, "Listener must not be null");
URI createUri = buildUrl("/images/create", "fromImage", reference.toString());
DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener();
listener.onStart();
try {
try (Response response = http().post(createUri)) {
try (Response response = http().post(createUri, registryAuth)) {
jsonStream().get(response.getContent(), PullImageUpdateEvent.class, (event) -> {
digestCapture.onUpdate(event);
listener.onUpdate(event);
......@@ -175,6 +188,33 @@ public class DockerApi {
}
}
/**
* Push an image to a registry.
* @param reference the image reference to push
* @param listener a push listener to receive update events
* @param registryAuth registry authentication credentials
* @throws IOException on IO error
*/
public void push(ImageReference reference, UpdateListener<PushImageUpdateEvent> listener, String registryAuth)
throws IOException {
Assert.notNull(reference, "Reference must not be null");
Assert.notNull(listener, "Listener must not be null");
URI pushUri = buildUrl("/images/" + reference + "/push");
ErrorCaptureUpdateListener errorListener = new ErrorCaptureUpdateListener();
listener.onStart();
try {
try (Response response = http().post(pushUri, registryAuth)) {
jsonStream().get(response.getContent(), PushImageUpdateEvent.class, (event) -> {
errorListener.onUpdate(event);
listener.onUpdate(event);
});
}
}
finally {
listener.onFinish();
}
}
/**
* Load an {@link ImageArchive} into Docker.
* @param archive the archive to load
......@@ -398,4 +438,18 @@ public class DockerApi {
}
/**
* {@link UpdateListener} used to capture the details of an error in a response
* stream.
*/
private static class ErrorCaptureUpdateListener implements UpdateListener<PushImageUpdateEvent> {
@Override
public void onUpdate(PushImageUpdateEvent event) {
Assert.state(event.getErrorDetail() == null,
() -> "Error response received when pushing image: " + event.getErrorDetail().getMessage());
}
}
}
/*
* 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;
* A {@link ProgressUpdateEvent} fired as an image is pulled.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0
*/
public class PullImageUpdateEvent extends ProgressUpdateEvent {
private final String id;
public class PullImageUpdateEvent extends ImageProgressUpdateEvent {
@JsonCreator
public PullImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) {
super(status, progressDetail, progress);
this.id = id;
}
/**
* Return the ID of the layer being updated if available.
* @return the ID of the updated layer or {@code null}
*/
public String getId() {
return this.id;
super(id, status, progressDetail, progress);
}
}
/*
* 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 @@
package org.springframework.boot.buildpack.platform.docker;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.springframework.boot.buildpack.platform.docker.ProgressUpdateEvent.ProgressDetail;
/**
* {@link UpdateListener} that calculates the total progress of the entire pull operation
* and publishes {@link TotalProgressEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0
*/
public class TotalProgressPullListener implements UpdateListener<PullImageUpdateEvent> {
private final Map<String, Layer> layers = new ConcurrentHashMap<>();
private final Consumer<TotalProgressEvent> consumer;
public class TotalProgressPullListener extends TotalProgressListener<PullImageUpdateEvent> {
private boolean progressStarted;
private static final String[] TRACKED_STATUS_KEYS = { "Downloading", "Extracting" };
/**
* Create a new {@link TotalProgressPullListener} that prints a progress bar to
......@@ -53,87 +46,7 @@ public class TotalProgressPullListener implements UpdateListener<PullImageUpdate
* events}
*/
public TotalProgressPullListener(Consumer<TotalProgressEvent> consumer) {
this.consumer = consumer;
}
@Override
public void onStart() {
}
@Override
public void onUpdate(PullImageUpdateEvent event) {
if (event.getId() != null) {
this.layers.computeIfAbsent(event.getId(), Layer::new).update(event);
}
this.progressStarted = this.progressStarted || event.getProgress() != null;
if (this.progressStarted) {
publish(0);
}
}
@Override
public void onFinish() {
this.layers.values().forEach(Layer::finish);
publish(100);
}
private void publish(int fallback) {
int count = 0;
int total = 0;
for (Layer layer : this.layers.values()) {
count++;
total += layer.getProgress();
}
TotalProgressEvent event = new TotalProgressEvent(
(count != 0) ? withinPercentageBounds(total / count) : fallback);
this.consumer.accept(event);
}
private static int withinPercentageBounds(int value) {
if (value < 0) {
return 0;
}
return Math.min(value, 100);
}
/**
* Progress for an individual layer.
*/
private static class Layer {
private int downloadProgress;
private int extractProgress;
Layer(String id) {
}
void update(PullImageUpdateEvent event) {
if (event.getProgressDetail() != null) {
ProgressDetail detail = event.getProgressDetail();
if ("Downloading".equals(event.getStatus())) {
this.downloadProgress = updateProgress(this.downloadProgress, detail);
}
if ("Extracting".equals(event.getStatus())) {
this.extractProgress = updateProgress(this.extractProgress, detail);
}
}
}
private int updateProgress(int current, ProgressDetail detail) {
int result = withinPercentageBounds((int) ((100.0 / detail.getTotal()) * detail.getCurrent()));
return Math.max(result, current);
}
void finish() {
this.downloadProgress = 100;
this.extractProgress = 100;
}
int getProgress() {
return withinPercentageBounds((this.downloadProgress + this.extractProgress) / 2);
}
super(consumer, TRACKED_STATUS_KEYS);
}
}
/*
* 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 {
private final DockerHost host;
private final DockerRegistryAuthentication authentication;
private final DockerRegistryAuthentication builderAuthentication;
private final DockerRegistryAuthentication publishAuthentication;
public DockerConfiguration() {
this(null, null);
this(null, null, null);
}
private DockerConfiguration(DockerHost host, DockerRegistryAuthentication authentication) {
private DockerConfiguration(DockerHost host, DockerRegistryAuthentication builderAuthentication,
DockerRegistryAuthentication publishAuthentication) {
this.host = host;
this.authentication = authentication;
this.builderAuthentication = builderAuthentication;
this.publishAuthentication = publishAuthentication;
}
public DockerHost getHost() {
return this.host;
}
public DockerRegistryAuthentication getRegistryAuthentication() {
return this.authentication;
public DockerRegistryAuthentication getBuilderRegistryAuthentication() {
return this.builderAuthentication;
}
public DockerRegistryAuthentication getPublishRegistryAuthentication() {
return this.publishAuthentication;
}
public DockerConfiguration withHost(String address, boolean secure, String certificatePath) {
Assert.notNull(address, "Address must not be null");
return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.authentication);
return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.builderAuthentication,
this.publishAuthentication);
}
public DockerConfiguration 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");
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) {
Assert.notNull(username, "Username must not be null");
Assert.notNull(password, "Password must not be null");
return new DockerConfiguration(this.host, new DockerRegistryUserAuthentication(username, password, url, email));
return new DockerConfiguration(this.host, this.builderAuthentication,
new DockerRegistryUserAuthentication(username, password, url, email));
}
}
......@@ -30,7 +30,7 @@ public class DockerHost {
private final String certificatePath;
protected DockerHost(String address, boolean secure, String certificatePath) {
public DockerHost(String address, boolean secure, String certificatePath) {
this.address = address;
this.secure = secure;
this.certificatePath = certificatePath;
......
......@@ -25,9 +25,9 @@ package org.springframework.boot.buildpack.platform.docker.configuration;
public interface DockerRegistryAuthentication {
/**
* Create the auth header that should be used for docker authentication.
* Returns the auth header that should be used for docker authentication.
* @return the auth header
*/
String createAuthHeader();
String getAuthHeader();
}
......@@ -30,6 +30,7 @@ class DockerRegistryTokenAuthentication extends JsonEncodedDockerRegistryAuthent
DockerRegistryTokenAuthentication(String token) {
this.token = token;
createAuthHeader();
}
String getToken() {
......
......@@ -42,6 +42,7 @@ class DockerRegistryUserAuthentication extends JsonEncodedDockerRegistryAuthenti
this.password = password;
this.url = url;
this.email = email;
createAuthHeader();
}
String getUsername() {
......
......@@ -22,17 +22,23 @@ import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
import org.springframework.util.Base64Utils;
/**
* {@link DockerRegistryAuthentication} that uses creates a Base64 encoded auth header
* value based on the JSON created from the instance.
* {@link DockerRegistryAuthentication} that uses a Base64 encoded auth header value based
* on the JSON created from the instance.
*
* @author Scott Frederick
*/
class JsonEncodedDockerRegistryAuthentication implements DockerRegistryAuthentication {
private String authHeader;
@Override
public String createAuthHeader() {
public String getAuthHeader() {
return this.authHeader;
}
protected void createAuthHeader() {
try {
return Base64Utils.encodeToUrlSafeString(SharedObjectMapper.get().writeValueAsBytes(this));
this.authHeader = Base64Utils.encodeToUrlSafeString(SharedObjectMapper.get().writeValueAsBytes(this));
}
catch (JsonProcessingException ex) {
throw new IllegalStateException("Error creating Docker registry authentication header", ex);
......
......@@ -36,7 +36,6 @@ import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.AbstractHttpEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.boot.buildpack.platform.io.Content;
import org.springframework.boot.buildpack.platform.io.IOConsumer;
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
......@@ -53,19 +52,17 @@ import org.springframework.util.StringUtils;
*/
abstract class HttpClientTransport implements HttpTransport {
static final String REGISTRY_AUTH_HEADER = "X-Registry-Auth";
private final CloseableHttpClient client;
private final HttpHost host;
private final String registryAuthHeader;
protected HttpClientTransport(CloseableHttpClient client, HttpHost host,
DockerRegistryAuthentication authentication) {
protected HttpClientTransport(CloseableHttpClient client, HttpHost host) {
Assert.notNull(client, "Client must not be null");
Assert.notNull(host, "Host must not be null");
this.client = client;
this.host = host;
this.registryAuthHeader = buildRegistryAuthHeader(authentication);
}
/**
......@@ -88,6 +85,17 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(new HttpPost(uri));
}
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
* @param registryAuth registry authentication credentials
* @return the operation response
*/
@Override
public Response post(URI uri, String registryAuth) {
return execute(new HttpPost(uri), registryAuth);
}
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
......@@ -122,11 +130,6 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(new HttpDelete(uri));
}
private String buildRegistryAuthHeader(DockerRegistryAuthentication authentication) {
String authHeader = (authentication != null) ? authentication.createAuthHeader() : null;
return (StringUtils.hasText(authHeader)) ? authHeader : null;
}
private Response execute(HttpEntityEnclosingRequestBase request, String contentType,
IOConsumer<OutputStream> writer) {
request.setHeader(HttpHeaders.CONTENT_TYPE, contentType);
......@@ -134,11 +137,15 @@ abstract class HttpClientTransport implements HttpTransport {
return execute(request);
}
private Response execute(HttpEntityEnclosingRequestBase request, String registryAuth) {
if (StringUtils.hasText(registryAuth)) {
request.setHeader(REGISTRY_AUTH_HEADER, registryAuth);
}
return execute(request);
}
private Response execute(HttpUriRequest request) {
try {
if (this.registryAuthHeader != null) {
request.addHeader("X-Registry-Auth", this.registryAuthHeader);
}
CloseableHttpResponse response = this.client.execute(this.host, request);
StatusLine statusLine = response.getStatusLine();
int statusCode = statusLine.getStatusCode();
......
......@@ -23,6 +23,7 @@ import java.io.OutputStream;
import java.net.URI;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
import org.springframework.boot.buildpack.platform.io.IOConsumer;
import org.springframework.boot.buildpack.platform.system.Environment;
......@@ -51,6 +52,15 @@ public interface HttpTransport {
*/
Response post(URI uri) throws IOException;
/**
* Perform a HTTP POST operation.
* @param uri the destination URI (excluding any host/port)
* @param registryAuth registry authentication credentials
* @return the operation response
* @throws IOException on IO error
*/
Response post(URI uri, String registryAuth) throws IOException;
/**
* Perform a HTTP POST operation.
* @param uri the destination URI (excluding any host/port)
......@@ -85,17 +95,17 @@ public interface HttpTransport {
* @return a {@link HttpTransport} instance
*/
static HttpTransport create() {
return create(new DockerConfiguration());
return create(Environment.SYSTEM);
}
/**
* Create the most suitable {@link HttpTransport} based on the
* {@link Environment#SYSTEM system environment}.
* @param dockerConfiguration the Docker engine configuration
* @param dockerHost the Docker engine host configuration
* @return a {@link HttpTransport} instance
*/
static HttpTransport create(DockerConfiguration dockerConfiguration) {
return create(Environment.SYSTEM, dockerConfiguration);
static HttpTransport create(DockerHost dockerHost) {
return create(Environment.SYSTEM, dockerHost);
}
/**
......@@ -105,19 +115,19 @@ public interface HttpTransport {
* @return a {@link HttpTransport} instance
*/
static HttpTransport create(Environment environment) {
return create(environment, new DockerConfiguration());
return create(environment, null);
}
/**
* Create the most suitable {@link HttpTransport} based on the given
* {@link Environment} and {@link DockerConfiguration}.
* @param environment the source environment
* @param dockerConfiguration the Docker engine configuration
* @param dockerHost the Docker engine host configuration
* @return a {@link HttpTransport} instance
*/
static HttpTransport create(Environment environment, DockerConfiguration dockerConfiguration) {
HttpTransport remote = RemoteHttpClientTransport.createIfPossible(environment, dockerConfiguration);
return (remote != null) ? remote : LocalHttpClientTransport.create(environment, dockerConfiguration);
static HttpTransport create(Environment environment, DockerHost dockerHost) {
HttpTransport remote = RemoteHttpClientTransport.createIfPossible(environment, dockerHost);
return (remote != null) ? remote : LocalHttpClientTransport.create(environment);
}
/**
......
......@@ -38,8 +38,6 @@ import org.apache.http.impl.conn.BasicHttpClientConnectionManager;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.Args;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.boot.buildpack.platform.socket.DomainSocket;
import org.springframework.boot.buildpack.platform.socket.NamedPipeSocket;
import org.springframework.boot.buildpack.platform.system.Environment;
......@@ -58,16 +56,15 @@ final class LocalHttpClientTransport extends HttpClientTransport {
private static final HttpHost LOCAL_DOCKER_HOST = HttpHost.create("docker://localhost");
private LocalHttpClientTransport(CloseableHttpClient client, DockerRegistryAuthentication authentication) {
super(client, LOCAL_DOCKER_HOST, authentication);
private LocalHttpClientTransport(CloseableHttpClient client) {
super(client, LOCAL_DOCKER_HOST);
}
static LocalHttpClientTransport create(Environment environment, DockerConfiguration dockerConfiguration) {
static LocalHttpClientTransport create(Environment environment) {
HttpClientBuilder builder = HttpClients.custom();
builder.setConnectionManager(new LocalConnectionManager(socketFilePath(environment)));
builder.setSchemePortResolver(new LocalSchemePortResolver());
return new LocalHttpClientTransport(builder.build(),
(dockerConfiguration != null) ? dockerConfiguration.getRegistryAuthentication() : null);
return new LocalHttpClientTransport(builder.build());
}
private static String socketFilePath(Environment environment) {
......
......@@ -28,9 +28,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory;
import org.springframework.boot.buildpack.platform.system.Environment;
import org.springframework.util.Assert;
......@@ -51,23 +49,21 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
private static final String DOCKER_CERT_PATH = "DOCKER_CERT_PATH";
private RemoteHttpClientTransport(CloseableHttpClient client, HttpHost host,
DockerRegistryAuthentication authentication) {
super(client, host, authentication);
private RemoteHttpClientTransport(CloseableHttpClient client, HttpHost host) {
super(client, host);
}
static RemoteHttpClientTransport createIfPossible(Environment environment,
DockerConfiguration dockerConfiguration) {
return createIfPossible(environment, dockerConfiguration, new SslContextFactory());
static RemoteHttpClientTransport createIfPossible(Environment environment, DockerHost dockerHost) {
return createIfPossible(environment, dockerHost, new SslContextFactory());
}
static RemoteHttpClientTransport createIfPossible(Environment environment, DockerConfiguration dockerConfiguration,
static RemoteHttpClientTransport createIfPossible(Environment environment, DockerHost dockerHost,
SslContextFactory sslContextFactory) {
DockerHost host = getHost(environment, dockerConfiguration);
DockerHost host = getHost(environment, dockerHost);
if (host == null || host.getAddress() == null || isLocalFileReference(host.getAddress())) {
return null;
}
return create(host, dockerConfiguration, sslContextFactory, HttpHost.create(host.getAddress()));
return create(host, sslContextFactory, HttpHost.create(host.getAddress()));
}
private static boolean isLocalFileReference(String host) {
......@@ -80,16 +76,15 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
}
}
private static RemoteHttpClientTransport create(DockerHost host, DockerConfiguration dockerConfiguration,
SslContextFactory sslContextFactory, HttpHost tcpHost) {
private static RemoteHttpClientTransport create(DockerHost host, SslContextFactory sslContextFactory,
HttpHost tcpHost) {
HttpClientBuilder builder = HttpClients.custom();
if (host.isSecure()) {
builder.setSSLSocketFactory(getSecureConnectionSocketFactory(host, sslContextFactory));
}
String scheme = host.isSecure() ? "https" : "http";
HttpHost httpHost = new HttpHost(tcpHost.getHostName(), tcpHost.getPort(), scheme);
return new RemoteHttpClientTransport(builder.build(), httpHost,
(dockerConfiguration != null) ? dockerConfiguration.getRegistryAuthentication() : null);
return new RemoteHttpClientTransport(builder.build(), httpHost);
}
private static LayeredConnectionSocketFactory getSecureConnectionSocketFactory(DockerHost host,
......@@ -101,14 +96,11 @@ final class RemoteHttpClientTransport extends HttpClientTransport {
return new SSLConnectionSocketFactory(sslContext);
}
private static DockerHost getHost(Environment environment, DockerConfiguration dockerConfiguration) {
private static DockerHost getHost(Environment environment, DockerHost dockerHost) {
if (environment.get(DOCKER_HOST) != null) {
return new EnvironmentDockerHost(environment);
}
if (dockerConfiguration != null && dockerConfiguration.getHost() != null) {
return dockerConfiguration.getHost();
}
return null;
return dockerHost;
}
private static class EnvironmentDockerHost extends DockerHost {
......
......@@ -30,6 +30,7 @@ import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi
import org.springframework.boot.buildpack.platform.docker.DockerApi.ImageApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
import org.springframework.boot.buildpack.platform.docker.TotalProgressPullListener;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.transport.DockerEngineException;
import org.springframework.boot.buildpack.platform.docker.type.ContainerReference;
import org.springframework.boot.buildpack.platform.docker.type.ContainerStatus;
......@@ -44,11 +45,13 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Tests for {@link Builder}.
......@@ -83,18 +86,53 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
builder.build(request);
assertThat(out.toString()).contains("Running creator");
assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull());
verify(docker.image()).pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull());
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verifyNoMoreInteractions(docker.image());
}
@Test
void buildInvokesBuilderAndPublishesImage() throws Exception {
TestPrintStream out = new TestPrintStream();
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
DockerConfiguration dockerConfiguration = new DockerConfiguration()
.withBuilderRegistryTokenAuthentication("builder token")
.withPublishRegistryTokenAuthentication("publish token");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader())))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker, dockerConfiguration);
BuildRequest request = getTestRequest().withPublish(true);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()));
verify(docker.image()).pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(),
eq(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()));
verify(docker.image()).push(eq(request.getName()), any(),
eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()));
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verifyNoMoreInteractions(docker.image());
}
@Test
......@@ -103,11 +141,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image-with-no-run-image-tag.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of("gcr.io/paketo-buildpacks/builder:latest")), any()))
given(docker.image().pull(eq(ImageReference.of("gcr.io/paketo-buildpacks/builder:latest")), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:latest")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:latest")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withBuilder(ImageReference.of("gcr.io/paketo-buildpacks/builder"));
builder.build(request);
assertThat(out.toString()).contains("Running creator");
......@@ -123,12 +161,12 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image-with-run-image-digest.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of(
"docker.io/cloudfoundry/run@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
any())).willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
any(), isNull())).willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
builder.build(request);
assertThat(out.toString()).contains("Running creator");
......@@ -144,11 +182,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("example.com/custom/run:latest")), any()))
given(docker.image().pull(eq(ImageReference.of("example.com/custom/run:latest")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withRunImage(ImageReference.of("example.com/custom/run:latest"));
builder.build(request);
assertThat(out.toString()).contains("Running creator");
......@@ -164,15 +202,15 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME))))
.willReturn(builderImage);
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))
.willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.NEVER);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
......@@ -190,15 +228,15 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME))))
.willReturn(builderImage);
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb"))))
.willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.ALWAYS);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
......@@ -206,7 +244,7 @@ class BuilderTests {
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verify(docker.image(), times(2)).pull(any(), any());
verify(docker.image(), times(2)).pull(any(), any(), isNull());
verify(docker.image(), never()).inspect(any());
}
......@@ -216,9 +254,9 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)))).willThrow(
new DockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, null))
......@@ -226,7 +264,7 @@ class BuilderTests {
given(docker.image().inspect(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")))).willThrow(
new DockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, null))
.willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.IF_NOT_PRESENT);
builder.build(request);
assertThat(out.toString()).contains("Running creator");
......@@ -235,7 +273,7 @@ class BuilderTests {
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verify(docker.image(), times(2)).inspect(any());
verify(docker.image(), times(2)).pull(any(), any());
verify(docker.image(), times(2)).pull(any(), any(), isNull());
}
@Test
......@@ -244,11 +282,11 @@ class BuilderTests {
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image-with-bad-stack.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
assertThatIllegalStateException().isThrownBy(() -> builder.build(request)).withMessage(
"Run image stack 'org.cloudfoundry.stacks.cfwindowsfs3' does not match builder stack 'io.buildpacks.stacks.bionic'");
......@@ -260,11 +298,11 @@ class BuilderTests {
DockerApi docker = mockDockerApiLifecycleError();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any()))
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:base-cnb")), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest();
assertThatExceptionOfType(BuilderException.class).isThrownBy(() -> builder.build(request))
.withMessage("Builder lifecycle 'creator' failed with status code 9");
......
......@@ -54,6 +54,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
......@@ -127,6 +128,9 @@ class DockerApiTests {
@Mock
private UpdateListener<PullImageUpdateEvent> pullListener;
@Mock
private UpdateListener<PushImageUpdateEvent> pushListener;
@Mock
private UpdateListener<LoadImageUpdateEvent> loadListener;
......@@ -156,7 +160,7 @@ class DockerApiTests {
URI createUri = new URI(IMAGES_URL + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase");
String imageHash = "4acb6bfd6c4f0cabaf7f3690e444afe51f1c7de54d51da7e63fac709c56f1c30";
URI imageUri = new URI(IMAGES_URL + "/gcr.io/paketo-buildpacks/builder@sha256:" + imageHash + "/json");
given(http().post(createUri)).willReturn(responseOf("pull-stream.json"));
given(http().post(eq(createUri), isNull())).willReturn(responseOf("pull-stream.json"));
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.pull(reference, this.pullListener);
assertThat(image.getLayers()).hasSize(46);
......@@ -166,6 +170,57 @@ class DockerApiTests {
ordered.verify(this.pullListener).onFinish();
}
@Test
void pullWithRegistryAuthPullsImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base");
URI createUri = new URI(IMAGES_URL + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase");
String imageHash = "4acb6bfd6c4f0cabaf7f3690e444afe51f1c7de54d51da7e63fac709c56f1c30";
URI imageUri = new URI(IMAGES_URL + "/gcr.io/paketo-buildpacks/builder@sha256:" + imageHash + "/json");
given(http().post(eq(createUri), eq("auth token"))).willReturn(responseOf("pull-stream.json"));
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.pull(reference, this.pullListener, "auth token");
assertThat(image.getLayers()).hasSize(46);
InOrder ordered = inOrder(this.pullListener);
ordered.verify(this.pullListener).onStart();
ordered.verify(this.pullListener, times(595)).onUpdate(any());
ordered.verify(this.pullListener).onFinish();
}
@Test
void pushWhenReferenceIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.push(null, this.pushListener, null))
.withMessage("Reference must not be null");
}
@Test
void pushWhenListenerIsNullThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.api.push(ImageReference.of("ubuntu"), null, null))
.withMessage("Listener must not be null");
}
@Test
void pushPushesImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("localhost:5000/ubuntu");
URI pushUri = new URI(IMAGES_URL + "/localhost:5000/ubuntu/push");
given(http().post(pushUri, "auth token")).willReturn(responseOf("push-stream.json"));
this.api.push(reference, this.pushListener, "auth token");
InOrder ordered = inOrder(this.pushListener);
ordered.verify(this.pushListener).onStart();
ordered.verify(this.pushListener, times(44)).onUpdate(any());
ordered.verify(this.pushListener).onFinish();
}
@Test
void pushWithErrorInStreamThrowsException() throws Exception {
ImageReference reference = ImageReference.of("localhost:5000/ubuntu");
URI pushUri = new URI(IMAGES_URL + "/localhost:5000/ubuntu/push");
given(http().post(pushUri, "auth token")).willReturn(responseOf("push-stream-with-error.json"));
assertThatIllegalStateException()
.isThrownBy(() -> this.api.push(reference, this.pushListener, "auth token"))
.withMessageContaining("test message");
}
@Test
void loadWhenArchiveIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.load(null, UpdateListener.none()))
......
......@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link LoadImageUpdateEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class LoadImageUpdateEventTests extends ProgressUpdateEventTests {
class LoadImageUpdateEventTests extends ProgressUpdateEventTests<LoadImageUpdateEvent> {
@Test
void getStreamReturnsStream() {
LoadImageUpdateEvent event = (LoadImageUpdateEvent) createEvent();
LoadImageUpdateEvent event = createEvent();
assertThat(event.getStream()).isEqualTo("stream");
}
@Override
protected ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
protected LoadImageUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
return new LoadImageUpdateEvent("stream", status, progressDetail, progress);
}
......
......@@ -26,8 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link ProgressUpdateEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
abstract class ProgressUpdateEventTests {
abstract class ProgressUpdateEventTests<E extends ProgressUpdateEvent> {
@Test
void getStatusReturnsStatus() {
......@@ -66,10 +67,10 @@ abstract class ProgressUpdateEventTests {
assertThat(ProgressDetail.isEmpty(detail)).isFalse();
}
protected ProgressUpdateEvent createEvent() {
protected E createEvent() {
return createEvent("status", new ProgressDetail(1, 2), "progress");
}
protected abstract ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress);
protected abstract E createEvent(String status, ProgressDetail progressDetail, String progress);
}
......@@ -26,17 +26,18 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link PullImageUpdateEvent}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class PullImageUpdateEventTests extends ProgressUpdateEventTests {
class PullImageUpdateEventTests extends ProgressUpdateEventTests<PullImageUpdateEvent> {
@Test
void getIdReturnsId() {
PullImageUpdateEvent event = (PullImageUpdateEvent) createEvent();
PullImageUpdateEvent event = createEvent();
assertThat(event.getId()).isEqualTo("id");
}
@Override
protected ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
protected PullImageUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
return new PullImageUpdateEvent("id", status, progressDetail, progress);
}
......
/*
* 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;
import java.util.List;
import java.util.function.Consumer;
import com.fasterxml.jackson.annotation.JsonCreator;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
......@@ -33,13 +34,14 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link TotalProgressPullListener}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class TotalProgressPullListenerTests extends AbstractJsonTests {
class TotalProgressListenerTests extends AbstractJsonTests {
@Test
void totalProgress() throws Exception {
List<Integer> progress = new ArrayList<>();
TotalProgressPullListener listener = new TotalProgressPullListener((event) -> progress.add(event.getPercent()));
TestTotalProgressListener listener = new TestTotalProgressListener((event) -> progress.add(event.getPercent()));
run(listener);
int last = 0;
for (Integer update : progress) {
......@@ -52,26 +54,25 @@ class TotalProgressPullListenerTests extends AbstractJsonTests {
@Test
@Disabled("For visual inspection")
void totalProgressUpdatesSmoothly() throws Exception {
TestTotalProgressPullListener listener = new TestTotalProgressPullListener(
new TotalProgressBar("Pulling layers:"));
TestTotalProgressListener listener = new TestTotalProgressListener(new TotalProgressBar("Pulling layers:"));
run(listener);
}
private void run(TotalProgressPullListener listener) throws IOException {
private void run(TestTotalProgressListener listener) throws IOException {
JsonStream jsonStream = new JsonStream(getObjectMapper());
listener.onStart();
jsonStream.get(getContent("pull-stream.json"), PullImageUpdateEvent.class, listener::onUpdate);
jsonStream.get(getContent("pull-stream.json"), TestImageUpdateEvent.class, listener::onUpdate);
listener.onFinish();
}
private static class TestTotalProgressPullListener extends TotalProgressPullListener {
private static class TestTotalProgressListener extends TotalProgressListener<TestImageUpdateEvent> {
TestTotalProgressPullListener(Consumer<TotalProgressEvent> consumer) {
super(consumer);
TestTotalProgressListener(Consumer<TotalProgressEvent> consumer) {
super(consumer, new String[] { "Pulling", "Downloading", "Extracting" });
}
@Override
public void onUpdate(PullImageUpdateEvent event) {
public void onUpdate(TestImageUpdateEvent event) {
super.onUpdate(event);
try {
Thread.sleep(10);
......@@ -82,4 +83,13 @@ class TotalProgressPullListenerTests extends AbstractJsonTests {
}
private static class TestImageUpdateEvent extends ImageProgressUpdateEvent {
@JsonCreator
TestImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) {
super(id, status, progressDetail, progress);
}
}
}
......@@ -31,14 +31,14 @@ public class DockerConfigurationTests {
@Test
void createDockerConfigurationWithDefaults() {
DockerConfiguration configuration = new DockerConfiguration();
assertThat(configuration.getRegistryAuthentication()).isNull();
assertThat(configuration.getBuilderRegistryAuthentication()).isNull();
}
@Test
void createDockerConfigurationWithUserAuth() {
DockerConfiguration configuration = new DockerConfiguration().withRegistryUserAuthentication("user", "secret",
"https://docker.example.com", "docker@example.com");
DockerRegistryAuthentication auth = configuration.getRegistryAuthentication();
DockerConfiguration configuration = new DockerConfiguration().withBuilderRegistryUserAuthentication("user",
"secret", "https://docker.example.com", "docker@example.com");
DockerRegistryAuthentication auth = configuration.getBuilderRegistryAuthentication();
assertThat(auth).isNotNull();
assertThat(auth).isInstanceOf(DockerRegistryUserAuthentication.class);
DockerRegistryUserAuthentication userAuth = (DockerRegistryUserAuthentication) auth;
......@@ -50,8 +50,8 @@ public class DockerConfigurationTests {
@Test
void createDockerConfigurationWithTokenAuth() {
DockerConfiguration configuration = new DockerConfiguration().withRegistryTokenAuthentication("token");
DockerRegistryAuthentication auth = configuration.getRegistryAuthentication();
DockerConfiguration configuration = new DockerConfiguration().withBuilderRegistryTokenAuthentication("token");
DockerRegistryAuthentication auth = configuration.getBuilderRegistryAuthentication();
assertThat(auth).isNotNull();
assertThat(auth).isInstanceOf(DockerRegistryTokenAuthentication.class);
DockerRegistryTokenAuthentication tokenAuth = (DockerRegistryTokenAuthentication) auth;
......
......@@ -29,13 +29,15 @@ import org.springframework.util.StreamUtils;
/**
* Tests for {@link DockerRegistryTokenAuthentication}.
*
* @author Scott Frederick
*/
class DockerRegistryTokenAuthenticationTests extends AbstractJsonTests {
@Test
void createAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryTokenAuthentication auth = new DockerRegistryTokenAuthentication("tokenvalue");
String header = auth.createAuthHeader();
String header = auth.getAuthHeader();
String expectedJson = StreamUtils.copyToString(getContent("auth-token.json"), StandardCharsets.UTF_8);
JSONAssert.assertEquals(expectedJson, new String(Base64Utils.decodeFromUrlSafeString(header)), false);
}
......
......@@ -29,6 +29,8 @@ import org.springframework.util.StreamUtils;
/**
* Tests for {@link DockerRegistryUserAuthentication}.
*
* @author Scott Frederick
*/
class DockerRegistryUserAuthenticationTests extends AbstractJsonTests {
......@@ -36,13 +38,13 @@ class DockerRegistryUserAuthenticationTests extends AbstractJsonTests {
void createMinimalAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret",
"https://docker.example.com", "docker@example.com");
JSONAssert.assertEquals(jsonContent("auth-user-full.json"), decoded(auth.createAuthHeader()), false);
JSONAssert.assertEquals(jsonContent("auth-user-full.json"), decoded(auth.getAuthHeader()), false);
}
@Test
void createFullAuthHeaderReturnsEncodedHeader() throws IOException, JSONException {
DockerRegistryUserAuthentication auth = new DockerRegistryUserAuthentication("user", "secret", null, null);
JSONAssert.assertEquals(jsonContent("auth-user-minimal.json"), decoded(auth.createAuthHeader()), false);
JSONAssert.assertEquals(jsonContent("auth-user-minimal.json"), decoded(auth.getAuthHeader()), false);
}
private String jsonContent(String s) throws IOException {
......
......@@ -22,7 +22,6 @@ import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHeaders;
......@@ -44,9 +43,7 @@ import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.transport.HttpTransport.Response;
import org.springframework.util.Base64Utils;
import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat;
......@@ -123,6 +120,37 @@ class HttpClientTransportTests {
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(request.getFirstHeader(HttpClientTransport.REGISTRY_AUTH_HEADER)).isNull();
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void postWithRegistryAuthShouldExecuteHttpPostWithHeader() throws Exception {
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.post(this.uri, "auth token");
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(request.getFirstHeader(HttpClientTransport.REGISTRY_AUTH_HEADER).getValue()).isEqualTo("auth token");
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void postWithEmptyRegistryAuthShouldExecuteHttpPostWithoutHeader() throws Exception {
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.post(this.uri, "");
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(request.getFirstHeader(HttpClientTransport.REGISTRY_AUTH_HEADER)).isNull();
assertThat(response.getContent()).isSameAs(this.content);
}
......@@ -237,47 +265,6 @@ class HttpClientTransportTests {
.satisfies((ex) -> assertThat(ex.getMessage()).contains("test IO exception"));
}
@Test
void getWithDockerRegistryUserAuthWillSendAuthHeader() throws IOException {
DockerConfiguration dockerConfiguration = new DockerConfiguration().withRegistryUserAuthentication("user",
"secret", "https://docker.example.com", "docker@example.com");
this.http = new TestHttpClientTransport(this.client, dockerConfiguration);
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.get(this.uri);
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpGet.class);
assertThat(request.getURI()).isEqualTo(this.uri);
Header[] registryAuthHeaders = request.getHeaders("X-Registry-Auth");
assertThat(registryAuthHeaders).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthHeaders[0].getValue())))
.contains("\"username\" : \"user\"").contains("\"password\" : \"secret\"")
.contains("\"email\" : \"docker@example.com\"")
.contains("\"serveraddress\" : \"https://docker.example.com\"");
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void getWithDockerRegistryTokenAuthWillSendAuthHeader() throws IOException {
DockerConfiguration dockerConfiguration = new DockerConfiguration().withRegistryTokenAuthentication("token");
this.http = new TestHttpClientTransport(this.client, dockerConfiguration);
givenClientWillReturnResponse();
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.get(this.uri);
verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpGet.class);
assertThat(request.getURI()).isEqualTo(this.uri);
Header[] registryAuthHeaders = request.getHeaders("X-Registry-Auth");
assertThat(registryAuthHeaders).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthHeaders[0].getValue())))
.contains("\"identitytoken\" : \"token\"");
assertThat(response.getContent()).isSameAs(this.content);
}
private String writeToString(HttpEntity entity) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
entity.writeTo(out);
......@@ -296,11 +283,7 @@ class HttpClientTransportTests {
static class TestHttpClientTransport extends HttpClientTransport {
protected TestHttpClientTransport(CloseableHttpClient client) {
super(client, HttpHost.create("docker://localhost"), null);
}
protected TestHttpClientTransport(CloseableHttpClient client, DockerConfiguration dockerConfiguration) {
super(client, HttpHost.create("docker://localhost"), dockerConfiguration.getRegistryAuthentication());
super(client, HttpHost.create("docker://localhost"));
}
}
......
......@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory;
import static org.assertj.core.api.Assertions.assertThat;
......@@ -52,7 +53,7 @@ class RemoteHttpClientTransportTests {
@Test
void createIfPossibleWhenDockerHostIsNotSetReturnsNull() {
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
new DockerHost(null, false, null));
assertThat(transport).isNull();
}
......@@ -67,8 +68,7 @@ class RemoteHttpClientTransportTests {
String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath()
.toString();
this.environment.put("DOCKER_HOST", dummySocketFilePath);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
assertThat(transport).isNull();
}
......@@ -77,22 +77,21 @@ class RemoteHttpClientTransportTests {
String dummySocketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath()
.toString();
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost(dummySocketFilePath, false, null));
new DockerHost(dummySocketFilePath, false, null));
assertThat(transport).isNull();
}
@Test
void createIfPossibleWhenDockerHostInEnvironmentIsAddressReturnsTransport() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
assertThat(transport).isNotNull();
}
@Test
void createIfPossibleWhenDockerHostInConfigurationIsAddressReturnsTransport() {
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", false, null));
new DockerHost("tcp://192.168.1.2:2376", false, null));
assertThat(transport).isNotNull();
}
......@@ -100,8 +99,8 @@ class RemoteHttpClientTransportTests {
void createIfPossibleWhenTlsVerifyInEnvironmentWithMissingCertPathThrowsException() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
this.environment.put("DOCKER_TLS_VERIFY", "1");
assertThatIllegalArgumentException().isThrownBy(
() -> RemoteHttpClientTransport.createIfPossible(this.environment::get, this.dockerConfiguration))
assertThatIllegalArgumentException()
.isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(this.environment::get, null))
.withMessageContaining("Docker host TLS verification requires trust material");
}
......@@ -109,15 +108,14 @@ class RemoteHttpClientTransportTests {
void createIfPossibleWhenTlsVerifyInConfigurationWithMissingCertPathThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", true, null)))
new DockerHost("tcp://192.168.1.2:2376", true, null)))
.withMessageContaining("Docker host TLS verification requires trust material");
}
@Test
void createIfPossibleWhenNoTlsVerifyUsesHttp() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration);
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get, null);
assertThat(transport.getHost()).satisfies(hostOf("http", "192.168.1.2", 2376));
}
......@@ -129,7 +127,7 @@ class RemoteHttpClientTransportTests {
SslContextFactory sslContextFactory = mock(SslContextFactory.class);
given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault());
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration, sslContextFactory);
this.dockerConfiguration.getHost(), sslContextFactory);
assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376));
}
......@@ -138,20 +136,11 @@ class RemoteHttpClientTransportTests {
SslContextFactory sslContextFactory = mock(SslContextFactory.class);
given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault());
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", true, "/test-cert-path"),
this.dockerConfiguration.withHost("tcp://192.168.1.2:2376", true, "/test-cert-path").getHost(),
sslContextFactory);
assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376));
}
@Test
void createIfPossibleWithDockerConfigurationUserAuthReturnsTransport() {
this.environment.put("DOCKER_HOST", "tcp://192.168.1.2:2376");
RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(this.environment::get,
new DockerConfiguration().withRegistryUserAuthentication("user", "secret", "http://docker.example.com",
"docker@example.com"));
assertThat(transport).isNotNull();
}
private Consumer<HttpHost> hostOf(String scheme, String hostName, int port) {
return (host) -> {
assertThat(host).isNotNull();
......
{
"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 {
testImplementation("org.assertj:assertj-core")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:testcontainers")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
......
......@@ -56,11 +56,14 @@ For more details, see also <<build-image-example-docker,examples>>.
[[build-image-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.
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
......@@ -133,6 +136,11 @@ Acceptable values are `ALWAYS`, `NEVER`, and `IF_NOT_PRESENT`.
|
| Enables verbose logging of builder operations.
| `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.
......@@ -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
----
[[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]]
==== 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:
......@@ -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]
----
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"]
.Groovy
......@@ -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]
----
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"]
.Groovy
......
......@@ -10,7 +10,7 @@ bootJar {
// tag::docker-auth-token[]
bootBuildImage {
docker {
registry {
builderRegistry {
token = "9cbaf023786cd7..."
}
}
......
......@@ -13,7 +13,7 @@ tasks.getByName<BootJar>("bootJar") {
// tag::docker-auth-token[]
tasks.getByName<BootBuildImage>("bootBuildImage") {
docker {
registry {
builderRegistry {
token = "9cbaf023786cd7..."
}
}
......
......@@ -10,7 +10,7 @@ bootJar {
// tag::docker-auth-user[]
bootBuildImage {
docker {
registry {
builderRegistry {
username = "user"
password = "secret"
url = "https://docker.example.com/v1/"
......
......@@ -13,7 +13,7 @@ tasks.getByName<BootJar>("bootJar") {
// tag::docker-auth-user[]
tasks.getByName<BootBuildImage>("bootBuildImage") {
docker {
registry {
builderRegistry {
username = "user"
password = "secret"
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;
import groovy.lang.Closure;
import org.gradle.api.Action;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.JavaVersion;
import org.gradle.api.Project;
import org.gradle.api.Task;
......@@ -76,6 +77,8 @@ public class BootBuildImage extends DefaultTask {
private PullPolicy pullPolicy;
private boolean publish;
private DockerSpec docker = new DockerSpec();
public BootBuildImage() {
......@@ -252,6 +255,24 @@ public class BootBuildImage extends DefaultTask {
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.
* @return docker configuration.
......@@ -312,6 +333,7 @@ public class BootBuildImage extends DefaultTask {
request = request.withCleanCache(this.cleanCache);
request = request.withVerboseLogging(this.verboseLogging);
request = customizePullPolicy(request);
request = customizePublish(request);
return request;
}
......@@ -354,6 +376,16 @@ public class BootBuildImage extends DefaultTask {
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() {
return this.targetJavaVersion.get().getMajorVersion() + ".*";
}
......
......@@ -41,14 +41,18 @@ public class DockerSpec {
private String certPath;
private final DockerRegistrySpec registry;
private final DockerRegistrySpec builderRegistry;
private final DockerRegistrySpec publishRegistry;
public DockerSpec() {
this.registry = new DockerRegistrySpec();
this.builderRegistry = new DockerRegistrySpec();
this.publishRegistry = new DockerRegistrySpec();
}
DockerSpec(DockerRegistrySpec registry) {
this.registry = registry;
DockerSpec(DockerRegistrySpec builderRegistry, DockerRegistrySpec publishRegistry) {
this.builderRegistry = builderRegistry;
this.publishRegistry = publishRegistry;
}
@Input
......@@ -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
*/
@Nested
public DockerRegistrySpec getRegistry() {
return this.registry;
public DockerRegistrySpec getPublishRegistry() {
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
*/
public void registry(Action<DockerRegistrySpec> action) {
action.execute(this.registry);
public void publishRegistry(Action<DockerRegistrySpec> action) {
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
*/
public void registry(Closure<?> closure) {
registry(ConfigureUtil.configureUsing(closure));
public void publishRegistry(Closure<?> closure) {
publishRegistry(ConfigureUtil.configureUsing(closure));
}
/**
......@@ -115,7 +150,8 @@ public class DockerSpec {
DockerConfiguration asDockerConfiguration() {
DockerConfiguration dockerConfiguration = new DockerConfiguration();
dockerConfiguration = customizeHost(dockerConfiguration);
dockerConfiguration = customizeAuthentication(dockerConfiguration);
dockerConfiguration = customizeBuilderAuthentication(dockerConfiguration);
dockerConfiguration = customizePublishAuthentication(dockerConfiguration);
return dockerConfiguration;
}
......@@ -126,19 +162,34 @@ public class DockerSpec {
return dockerConfiguration;
}
private DockerConfiguration customizeAuthentication(DockerConfiguration dockerConfiguration) {
if (this.registry == null || this.registry.hasEmptyAuth()) {
private DockerConfiguration customizeBuilderAuthentication(DockerConfiguration dockerConfiguration) {
if (this.builderRegistry == null || this.builderRegistry.hasEmptyAuth()) {
return dockerConfiguration;
}
if (this.registry.hasTokenAuth() && !this.registry.hasUserAuth()) {
return dockerConfiguration.withRegistryTokenAuthentication(this.registry.getToken());
if (this.builderRegistry.hasTokenAuth() && !this.builderRegistry.hasUserAuth()) {
return dockerConfiguration.withBuilderRegistryTokenAuthentication(this.builderRegistry.getToken());
}
if (this.registry.hasUserAuth() && !this.registry.hasTokenAuth()) {
return dockerConfiguration.withRegistryUserAuthentication(this.registry.getUsername(),
this.registry.getPassword(), this.registry.getUrl(), this.registry.getEmail());
if (this.builderRegistry.hasUserAuth() && !this.builderRegistry.hasTokenAuth()) {
return dockerConfiguration.withBuilderRegistryUserAuthentication(this.builderRegistry.getUsername(),
this.builderRegistry.getPassword(), this.builderRegistry.getUrl(), this.builderRegistry.getEmail());
}
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 {
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.
* @return the registry username
......
......@@ -178,6 +178,15 @@ class BootBuildImageIntegrationTests {
.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() {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
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;
import java.util.HashMap;
import java.util.Map;
import org.gradle.api.GradleException;
import org.gradle.api.JavaVersion;
import org.gradle.api.Project;
import org.gradle.testfixtures.ProjectBuilder;
......@@ -30,6 +31,7 @@ import org.springframework.boot.buildpack.platform.build.BuildRequest;
import org.springframework.boot.buildpack.platform.build.PullPolicy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link BootBuildImage}.
......@@ -174,6 +176,18 @@ class BootBuildImageTests {
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
void whenNoBuilderIsConfiguredThenRequestHasDefaultBuilder() {
assertThat(this.buildImage.createRequest().getBuilder().getName()).isEqualTo("paketo-buildpacks/builder");
......
......@@ -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.DockerHost;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.util.Base64Utils;
import static org.assertj.core.api.Assertions.assertThat;
......@@ -39,7 +38,8 @@ public class DockerSpecTests {
void asDockerConfigurationWithDefaults() {
DockerSpec dockerSpec = new DockerSpec();
assertThat(dockerSpec.asDockerConfiguration().getHost()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
}
@Test
......@@ -53,7 +53,8 @@ public class DockerSpecTests {
assertThat(host.getAddress()).isEqualTo("docker.example.com");
assertThat(host.isSecure()).isEqualTo(true);
assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert");
assertThat(dockerSpec.asDockerConfiguration().getRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
}
@Test
......@@ -65,59 +66,71 @@ public class DockerSpecTests {
assertThat(host.getAddress()).isEqualTo("docker.example.com");
assertThat(host.isSecure()).isEqualTo(false);
assertThat(host.getCertificatePath()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(dockerSpec.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
}
@Test
void asDockerConfigurationWithUserAuth() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec();
dockerRegistry.setUsername("user");
dockerRegistry.setPassword("secret");
dockerRegistry.setUrl("https://docker.example.com");
dockerRegistry.setEmail("docker@example.com");
DockerSpec dockerSpec = new DockerSpec(dockerRegistry);
DockerSpec dockerSpec = new DockerSpec(
new DockerSpec.DockerRegistrySpec("user1", "secret1", "https://docker1.example.com",
"docker1@example.com"),
new DockerSpec.DockerRegistrySpec("user2", "secret2", "https://docker2.example.com",
"docker2@example.com"));
DockerConfiguration dockerConfiguration = dockerSpec.asDockerConfiguration();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication();
assertThat(registryAuthentication).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader())))
.contains("\"username\" : \"user\"").contains("\"password\" : \"secret\"")
.contains("\"email\" : \"docker@example.com\"")
.contains("\"serveraddress\" : \"https://docker.example.com\"");
assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
.contains("\"username\" : \"user1\"").contains("\"password\" : \"secret1\"")
.contains("\"email\" : \"docker1@example.com\"")
.contains("\"serveraddress\" : \"https://docker1.example.com\"");
assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"username\" : \"user2\"").contains("\"password\" : \"secret2\"")
.contains("\"email\" : \"docker2@example.com\"")
.contains("\"serveraddress\" : \"https://docker2.example.com\"");
assertThat(dockerSpec.asDockerConfiguration().getHost()).isNull();
}
@Test
void asDockerConfigurationWithIncompleteUserAuthFails() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec();
dockerRegistry.setUsername("user");
dockerRegistry.setUrl("https://docker.example.com");
dockerRegistry.setEmail("docker@example.com");
DockerSpec dockerSpec = new DockerSpec(dockerRegistry);
void asDockerConfigurationWithIncompleteBuilderUserAuthFails() {
DockerSpec.DockerRegistrySpec builderRegistry = new DockerSpec.DockerRegistrySpec("user", null,
"https://docker.example.com", "docker@example.com");
DockerSpec dockerSpec = new DockerSpec(builderRegistry, null);
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
void asDockerConfigurationWithTokenAuth() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec();
dockerRegistry.setToken("token");
DockerSpec dockerSpec = new DockerSpec(dockerRegistry);
DockerSpec dockerSpec = new DockerSpec(new DockerSpec.DockerRegistrySpec("token1"),
new DockerSpec.DockerRegistrySpec("token2"));
DockerConfiguration dockerConfiguration = dockerSpec.asDockerConfiguration();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication();
assertThat(registryAuthentication).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader())))
.contains("\"identitytoken\" : \"token\"");
assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
.contains("\"identitytoken\" : \"token1\"");
assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"identitytoken\" : \"token2\"");
}
@Test
void asDockerConfigurationWithUserAndTokenAuthFails() {
DockerSpec.DockerRegistrySpec dockerRegistry = new DockerSpec.DockerRegistrySpec();
dockerRegistry.setUsername("user");
dockerRegistry.setPassword("secret");
dockerRegistry.setToken("token");
DockerSpec dockerSpec = new DockerSpec(dockerRegistry);
DockerSpec.DockerRegistrySpec builderRegistry = new DockerSpec.DockerRegistrySpec();
builderRegistry.setUsername("user");
builderRegistry.setPassword("secret");
builderRegistry.setToken("token");
DockerSpec dockerSpec = new DockerSpec(builderRegistry, null);
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 {
intTestImplementation("org.assertj:assertj-core")
intTestImplementation("org.junit.jupiter:junit-jupiter")
intTestImplementation("org.testcontainers:testcontainers")
intTestImplementation("org.testcontainers:junit-jupiter")
optional("org.apache.maven.plugins:maven-shade-plugin")
......
......@@ -79,11 +79,14 @@ For more details, see also <<build-image-example-docker,examples>>.
[[build-image-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.
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
......@@ -156,6 +159,11 @@ Acceptable values are `ALWAYS`, `NEVER`, and `IF_NOT_PRESENT`.
| Enables verbose logging of builder operations.
|
| `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.
......@@ -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]]
==== 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:
......@@ -329,7 +379,7 @@ If you need the plugin to communicate with the Docker daemon using a remote conn
</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"]
----
......@@ -342,12 +392,12 @@ If the builder or run image are stored in a private Docker registry that support
<version>{gradle-project-version}</version>
<configuration>
<docker>
<registry>
<builderRegistry>
<username>user</username>
<password>secret</password>
<url>https://docker.example.com/v1/</url>
<email>user@example.com</email>
</registry>
</builderRegistry>
</docker>
</configuration>
</plugin>
......@@ -356,7 +406,7 @@ If the builder or run image are stored in a private Docker registry that support
</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"]
----
......@@ -369,9 +419,9 @@ If the builder or run image is stored in a private Docker registry that supports
<version>{gradle-project-version}</version>
<configuration>
<docker>
<registry>
<builderRegistry>
<token>9cbaf023786cd7...</token>
</registry>
</builderRegistry>
</docker>
</configuration>
</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 {
});
}
@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
void failsWhenBuilderFails(MavenBuild mavenBuild) {
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 {
private String classifier;
/**
* Image configuration, with `builder`, `runImage`, `name`, `env`, `cleanCache` and
* `verboseLogging` options.
* Image configuration, with `builder`, `runImage`, `name`, `env`, `cleanCache`,
* `verboseLogging`, and `publish` options.
* @since 2.3.0
*/
@Parameter
......@@ -136,6 +136,12 @@ public class BuildImageMojo extends AbstractPackagerMojo {
@Parameter(property = "spring-boot.build-image.pullPolicy", readonly = true)
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.
* @since 2.4.0
......@@ -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);
Image image = (this.image != null) ? this.image : new Image();
if (image.name == null && this.imageName != null) {
......@@ -185,9 +191,20 @@ public class BuildImageMojo extends AbstractPackagerMojo {
if (image.pullPolicy == null && this.pullPolicy != null) {
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));
}
private boolean publishRegistryNotConfigured() {
return this.docker == null || this.docker.getPublishRegistry() == null
|| this.docker.getPublishRegistry().isEmpty();
}
private TarArchive getApplicationContent(Owner owner, Libraries libraries) {
ImagePackager packager = getConfiguredPackager(() -> new ImagePackager(getJarFile()));
return new PackagedTarArchive(owner, libraries, packager);
......
......@@ -33,7 +33,9 @@ public class Docker {
private String certPath;
private DockerRegistry registry;
private DockerRegistry builderRegistry;
private DockerRegistry publishRegistry;
public String getHost() {
return this.host;
......@@ -59,12 +61,30 @@ public class Docker {
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.
* @param registry the registry configuration
* Sets the {@link DockerRegistry} that configures authentication to the publishing
* registry.
* @param builderRegistry the registry configuration
*/
public void setRegistry(DockerRegistry registry) {
this.registry = registry;
public void setPublishRegistry(DockerRegistry builderRegistry) {
this.publishRegistry = builderRegistry;
}
/**
......@@ -76,7 +96,8 @@ public class Docker {
DockerConfiguration asDockerConfiguration() {
DockerConfiguration dockerConfiguration = new DockerConfiguration();
dockerConfiguration = customizeHost(dockerConfiguration);
dockerConfiguration = customizeAuthentication(dockerConfiguration);
dockerConfiguration = customizeBuilderAuthentication(dockerConfiguration);
dockerConfiguration = customizePublishAuthentication(dockerConfiguration);
return dockerConfiguration;
}
......@@ -87,19 +108,34 @@ public class Docker {
return dockerConfiguration;
}
private DockerConfiguration customizeAuthentication(DockerConfiguration dockerConfiguration) {
if (this.registry == null || this.registry.isEmpty()) {
private DockerConfiguration customizeBuilderAuthentication(DockerConfiguration dockerConfiguration) {
if (this.builderRegistry == null || this.builderRegistry.isEmpty()) {
return dockerConfiguration;
}
if (this.registry.hasTokenAuth() && !this.registry.hasUserAuth()) {
return dockerConfiguration.withRegistryTokenAuthentication(this.registry.getToken());
if (this.builderRegistry.hasTokenAuth() && !this.builderRegistry.hasUserAuth()) {
return dockerConfiguration.withBuilderRegistryTokenAuthentication(this.builderRegistry.getToken());
}
if (this.registry.hasUserAuth() && !this.registry.hasTokenAuth()) {
return dockerConfiguration.withRegistryUserAuthentication(this.registry.getUsername(),
this.registry.getPassword(), this.registry.getUrl(), this.registry.getEmail());
if (this.builderRegistry.hasUserAuth() && !this.builderRegistry.hasTokenAuth()) {
return dockerConfiguration.withBuilderRegistryUserAuthentication(this.builderRegistry.getUsername(),
this.builderRegistry.getPassword(), this.builderRegistry.getUrl(), this.builderRegistry.getEmail());
}
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 {
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() {
return this.username;
}
......
......@@ -73,6 +73,11 @@ public class Image {
*/
PullPolicy pullPolicy;
/**
* If the built image should be pushed to a registry.
*/
Boolean publish;
void setName(String name) {
this.name = name;
}
......@@ -89,6 +94,10 @@ public class Image {
this.pullPolicy = pullPolicy;
}
public void setPublish(Boolean publish) {
this.publish = publish;
}
BuildRequest getBuildRequest(Artifact artifact, Function<Owner, TarArchive> applicationContent) {
return customize(BuildRequest.of(getOrDeduceName(artifact), applicationContent));
}
......@@ -116,6 +125,9 @@ public class Image {
if (this.pullPolicy != null) {
request = request.withPullPolicy(this.pullPolicy);
}
if (this.publish != null) {
request = request.withPublish(this.publish);
}
return request;
}
......
......@@ -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.DockerHost;
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
import org.springframework.util.Base64Utils;
import static org.assertj.core.api.Assertions.assertThat;
......@@ -38,7 +37,8 @@ public class DockerTests {
void asDockerConfigurationWithDefaults() {
Docker docker = new Docker();
assertThat(docker.asDockerConfiguration().getHost()).isNull();
assertThat(docker.asDockerConfiguration().getRegistryAuthentication()).isNull();
assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(docker.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
}
@Test
......@@ -52,50 +52,56 @@ public class DockerTests {
assertThat(host.getAddress()).isEqualTo("docker.example.com");
assertThat(host.isSecure()).isEqualTo(true);
assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert");
assertThat(docker.asDockerConfiguration().getRegistryAuthentication()).isNull();
assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
assertThat(docker.asDockerConfiguration().getPublishRegistryAuthentication()).isNull();
}
@Test
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.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();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication();
assertThat(registryAuthentication).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader())))
.contains("\"username\" : \"user\"").contains("\"password\" : \"secret\"")
.contains("\"email\" : \"docker@example.com\"")
.contains("\"serveraddress\" : \"https://docker.example.com\"");
assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
.contains("\"username\" : \"user1\"").contains("\"password\" : \"secret1\"")
.contains("\"email\" : \"docker1@example.com\"")
.contains("\"serveraddress\" : \"https://docker1.example.com\"");
assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"username\" : \"user2\"").contains("\"password\" : \"secret2\"")
.contains("\"email\" : \"docker2@example.com\"")
.contains("\"serveraddress\" : \"https://docker2.example.com\"");
}
@Test
void asDockerConfigurationWithIncompleteUserAuthFails() {
Docker.DockerRegistry dockerRegistry = new Docker.DockerRegistry();
dockerRegistry.setUsername("user");
dockerRegistry.setUrl("https://docker.example.com");
dockerRegistry.setEmail("docker@example.com");
void asDockerConfigurationWithIncompleteBuilderUserAuthFails() {
Docker docker = new Docker();
docker.setBuilderRegistry(
new Docker.DockerRegistry("user", null, "https://docker.example.com", "docker@example.com"));
assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration)
.withMessageContaining("Invalid Docker builder registry configuration");
}
@Test
void asDockerConfigurationWithIncompletePublishUserAuthFails() {
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)
.withMessageContaining("Invalid Docker registry configuration");
.withMessageContaining("Invalid Docker publish registry configuration");
}
@Test
void asDockerConfigurationWithTokenAuth() {
Docker.DockerRegistry dockerRegistry = new Docker.DockerRegistry();
dockerRegistry.setToken("token");
Docker docker = new Docker();
docker.setRegistry(dockerRegistry);
docker.setBuilderRegistry(new Docker.DockerRegistry("token1"));
docker.setPublishRegistry(new Docker.DockerRegistry("token2"));
DockerConfiguration dockerConfiguration = docker.asDockerConfiguration();
DockerRegistryAuthentication registryAuthentication = dockerConfiguration.getRegistryAuthentication();
assertThat(registryAuthentication).isNotNull();
assertThat(new String(Base64Utils.decodeFromString(registryAuthentication.createAuthHeader())))
.contains("\"identitytoken\" : \"token\"");
assertThat(decoded(dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader()))
.contains("\"identitytoken\" : \"token1\"");
assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
.contains("\"identitytoken\" : \"token2\"");
}
@Test
......@@ -105,9 +111,13 @@ public class DockerTests {
dockerRegistry.setPassword("secret");
dockerRegistry.setToken("token");
Docker docker = new Docker();
docker.setRegistry(dockerRegistry);
docker.setBuilderRegistry(dockerRegistry);
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 {
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() {
return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile",
"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