From 09b627d2326fc1e1aa17158d07732e7dd441751f Mon Sep 17 00:00:00 2001 From: Scott Frederick Date: Fri, 18 Sep 2020 17:59:10 -0500 Subject: [PATCH] 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 --- .../platform/build/AbstractBuildLog.java | 10 ++ .../buildpack/platform/build/BuildLog.java | 15 +- .../platform/build/BuildRequest.java | 41 ++++-- .../buildpack/platform/build/Builder.java | 32 ++++- .../buildpack/platform/docker/DockerApi.java | 58 +++++++- .../docker/ImageProgressUpdateEvent.java | 43 ++++++ .../platform/docker/PullImageUpdateEvent.java | 16 +-- .../platform/docker/PushImageUpdateEvent.java | 74 ++++++++++ .../docker/TotalProgressListener.java | 133 ++++++++++++++++++ .../docker/TotalProgressPullListener.java | 95 +------------ .../docker/TotalProgressPushListener.java | 51 +++++++ .../configuration/DockerConfiguration.java | 47 +++++-- .../docker/configuration/DockerHost.java | 2 +- .../DockerRegistryAuthentication.java | 4 +- .../DockerRegistryTokenAuthentication.java | 1 + .../DockerRegistryUserAuthentication.java | 1 + ...onEncodedDockerRegistryAuthentication.java | 14 +- .../docker/transport/HttpClientTransport.java | 35 +++-- .../docker/transport/HttpTransport.java | 28 ++-- .../transport/LocalHttpClientTransport.java | 11 +- .../transport/RemoteHttpClientTransport.java | 32 ++--- .../platform/build/BuilderTests.java | 96 +++++++++---- .../platform/docker/DockerApiTests.java | 57 +++++++- .../docker/LoadImageUpdateEventTests.java | 7 +- .../docker/ProgressUpdateEventTests.java | 7 +- .../docker/PullImageUpdateEventTests.java | 7 +- .../docker/PushImageUpdateEventTests.java | 50 +++++++ ...s.java => TotalProgressListenerTests.java} | 30 ++-- .../DockerConfigurationTests.java | 12 +- ...ockerRegistryTokenAuthenticationTests.java | 4 +- ...DockerRegistryUserAuthenticationTests.java | 6 +- .../transport/HttpClientTransportTests.java | 81 +++++------ .../RemoteHttpClientTransportTests.java | 35 ++--- .../docker/push-stream-with-error.json | 7 + .../platform/docker/push-stream.json | 46 ++++++ .../spring-boot-gradle-plugin/build.gradle | 1 + .../docs/asciidoc/packaging-oci-image.adoc | 43 +++++- .../boot-build-image-docker-auth-token.gradle | 2 +- ...t-build-image-docker-auth-token.gradle.kts | 2 +- .../boot-build-image-docker-auth-user.gradle | 2 +- ...ot-build-image-docker-auth-user.gradle.kts | 2 +- .../packaging/boot-build-image-publish.gradle | 23 +++ .../boot-build-image-publish.gradle.kts | 26 ++++ .../gradle/tasks/bundling/BootBuildImage.java | 32 +++++ .../gradle/tasks/bundling/DockerSpec.java | 109 +++++++++++--- .../BootBuildImageIntegrationTests.java | 9 ++ ...ootBuildImageRegistryIntegrationTests.java | 112 +++++++++++++++ .../tasks/bundling/BootBuildImageTests.java | 14 ++ .../tasks/bundling/DockerSpecTests.java | 85 ++++++----- ...tBuildImageRegistryIntegrationTests.gradle | 17 +++ .../spring-boot-maven-plugin/build.gradle | 1 + .../docs/asciidoc/packaging-oci-image.adoc | 70 +++++++-- .../BuildImageRegistryIntegrationTests.java | 87 ++++++++++++ .../boot/maven/BuildImageTests.java | 6 + .../projects/build-image-publish/pom.xml | 40 ++++++ .../main/java/org/test/SampleApplication.java | 28 ++++ .../boot/maven/BuildImageMojo.java | 23 ++- .../springframework/boot/maven/Docker.java | 78 ++++++++-- .../org/springframework/boot/maven/Image.java | 12 ++ .../boot/maven/DockerTests.java | 72 ++++++---- .../boot/maven/ImageTests.java | 8 ++ 61 files changed, 1648 insertions(+), 444 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ImageProgressUpdateEvent.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEvent.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressListener.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPushListener.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEventTests.java rename spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/{TotalProgressPullListenerTests.java => TotalProgressListenerTests.java} (65%) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream-with-error.json create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream.json create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle.kts create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageRegistryIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/pom.xml create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/src/main/java/org/test/SampleApplication.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java index 863f82e3080..d3d818ff487 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java @@ -75,6 +75,16 @@ public abstract class AbstractBuildLog implements BuildLog { log(String.format(" > Pulled %s '%s'", imageType.getDescription(), getDigest(image))); } + @Override + public Consumer 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); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java index cee0eafc8fe..c22680108b7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java @@ -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 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 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java index 7f65984b532..c4ccd59916d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java @@ -59,6 +59,8 @@ public class BuildRequest { private final PullPolicy pullPolicy; + private final boolean publish; + BuildRequest(ImageReference name, Function 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 applicationContent, ImageReference builder, ImageReference runImage, Creator creator, Map 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 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 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 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java index 65adfa69ba4..7cb1d86e0f5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java @@ -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 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 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); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java index 8d633c7f8b0..aaff3ccb20b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java @@ -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 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 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 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 { + + @Override + public void onUpdate(PushImageUpdateEvent event) { + Assert.state(event.getErrorDetail() == null, + () -> "Error response received when pushing image: " + event.getErrorDetail().getMessage()); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ImageProgressUpdateEvent.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ImageProgressUpdateEvent.java new file mode 100644 index 00000000000..ba2878ab3e6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ImageProgressUpdateEvent.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker; + +/** + * A {@link ProgressUpdateEvent} fired for image events. + * + * @author Phillip Webb + * @author Scott Frederick + * @since 2.4.0 + */ +public class ImageProgressUpdateEvent extends ProgressUpdateEvent { + + private final String id; + + protected ImageProgressUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) { + super(status, progressDetail, progress); + this.id = id; + } + + /** + * Returns the ID of the image layer being updated if available. + * @return the ID of the updated layer or {@code null} + */ + public String getId() { + return this.id; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEvent.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEvent.java index 8422084c10a..73152e3a087 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEvent.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEvent.java @@ -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); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEvent.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEvent.java new file mode 100644 index 00000000000..2ebfa6638bc --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEvent.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A {@link ProgressUpdateEvent} fired as an image is pushed to a registry. + * + * @author Scott Frederick + * @since 2.4.0 + */ +public class PushImageUpdateEvent extends ImageProgressUpdateEvent { + + private final ErrorDetail errorDetail; + + @JsonCreator + public PushImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress, + ErrorDetail errorDetail) { + super(id, status, progressDetail, progress); + this.errorDetail = errorDetail; + } + + /** + * Returns the details of any error encountered during processing. + * @return the error + */ + public ErrorDetail getErrorDetail() { + return this.errorDetail; + } + + /** + * Details of an error embedded in a response stream. + */ + public static class ErrorDetail { + + private final String message; + + @JsonCreator + public ErrorDetail(@JsonProperty("message") String message) { + this.message = message; + } + + /** + * Returns the message field from the error detail. + * @return the message + */ + public String getMessage() { + return this.message; + } + + @Override + public String toString() { + return this.message; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressListener.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressListener.java new file mode 100644 index 00000000000..fa397c611f5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressListener.java @@ -0,0 +1,133 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +import org.springframework.boot.buildpack.platform.docker.ProgressUpdateEvent.ProgressDetail; + +/** + * {@link UpdateListener} that calculates the total progress of the entire image operation + * and publishes {@link TotalProgressEvent}. + * + * @param the type of {@link ImageProgressUpdateEvent} + * @author Phillip Webb + * @author Scott Frederick + * @since 2.4.0 + */ +public abstract class TotalProgressListener implements UpdateListener { + + private final Map layers = new ConcurrentHashMap<>(); + + private final Consumer 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 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 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()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPullListener.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPullListener.java index e8e8bf30dc8..dec52093ebd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPullListener.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPullListener.java @@ -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 { +public class TotalProgressPullListener extends TotalProgressListener { - private final Map layers = new ConcurrentHashMap<>(); - - private final Consumer consumer; - - private boolean progressStarted; + private static final String[] TRACKED_STATUS_KEYS = { "Downloading", "Extracting" }; /** * Create a new {@link TotalProgressPullListener} that prints a progress bar to @@ -53,87 +46,7 @@ public class TotalProgressPullListener implements UpdateListener 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); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPushListener.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPushListener.java new file mode 100644 index 00000000000..ff5516d7764 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPushListener.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker; + +import java.util.function.Consumer; + +/** + * {@link UpdateListener} that calculates the total progress of the entire push operation + * and publishes {@link TotalProgressEvent}. + * + * @author Scott Frederick + * @since 2.4.0 + */ +public class TotalProgressPushListener extends TotalProgressListener { + + 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 consumer) { + super(consumer, TRACKED_STATUS_KEYS); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java index ca0f354d643..68135780922 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java @@ -29,40 +29,65 @@ public final class DockerConfiguration { private final DockerHost host; - private final DockerRegistryAuthentication authentication; + private final DockerRegistryAuthentication builderAuthentication; + + private final DockerRegistryAuthentication publishAuthentication; public DockerConfiguration() { - this(null, null); + this(null, null, null); } - private DockerConfiguration(DockerHost host, DockerRegistryAuthentication authentication) { + private DockerConfiguration(DockerHost host, DockerRegistryAuthentication builderAuthentication, + DockerRegistryAuthentication publishAuthentication) { this.host = host; - this.authentication = authentication; + this.builderAuthentication = builderAuthentication; + this.publishAuthentication = publishAuthentication; } public DockerHost getHost() { return this.host; } - public DockerRegistryAuthentication getRegistryAuthentication() { - return this.authentication; + public DockerRegistryAuthentication getBuilderRegistryAuthentication() { + return this.builderAuthentication; + } + + public DockerRegistryAuthentication getPublishRegistryAuthentication() { + return this.publishAuthentication; } public DockerConfiguration withHost(String address, boolean secure, String certificatePath) { Assert.notNull(address, "Address must not be null"); - return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.authentication); + return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.builderAuthentication, + this.publishAuthentication); } - public DockerConfiguration withRegistryTokenAuthentication(String token) { + public DockerConfiguration withBuilderRegistryTokenAuthentication(String token) { Assert.notNull(token, "Token must not be null"); - return new DockerConfiguration(this.host, new DockerRegistryTokenAuthentication(token)); + return new DockerConfiguration(this.host, new DockerRegistryTokenAuthentication(token), + this.publishAuthentication); } - public DockerConfiguration withRegistryUserAuthentication(String username, String password, String url, + public DockerConfiguration withBuilderRegistryUserAuthentication(String username, String password, String url, String email) { Assert.notNull(username, "Username must not be null"); Assert.notNull(password, "Password must not be null"); - return new DockerConfiguration(this.host, new DockerRegistryUserAuthentication(username, password, url, email)); + return new DockerConfiguration(this.host, new DockerRegistryUserAuthentication(username, password, url, email), + this.publishAuthentication); + } + + public DockerConfiguration withPublishRegistryTokenAuthentication(String token) { + Assert.notNull(token, "Token must not be null"); + return new DockerConfiguration(this.host, this.builderAuthentication, + new DockerRegistryTokenAuthentication(token)); + } + + public DockerConfiguration withPublishRegistryUserAuthentication(String username, String password, String url, + String email) { + Assert.notNull(username, "Username must not be null"); + Assert.notNull(password, "Password must not be null"); + return new DockerConfiguration(this.host, this.builderAuthentication, + new DockerRegistryUserAuthentication(username, password, url, email)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java index facf91c012e..024b587f29f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java @@ -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; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java index 65324b295cc..3df4b4fadcb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java @@ -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(); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthentication.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthentication.java index 31d928c83ca..d0923c22cd1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthentication.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthentication.java @@ -30,6 +30,7 @@ class DockerRegistryTokenAuthentication extends JsonEncodedDockerRegistryAuthent DockerRegistryTokenAuthentication(String token) { this.token = token; + createAuthHeader(); } String getToken() { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthentication.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthentication.java index 2f040a10524..c5a068e7301 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthentication.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthentication.java @@ -42,6 +42,7 @@ class DockerRegistryUserAuthentication extends JsonEncodedDockerRegistryAuthenti this.password = password; this.url = url; this.email = email; + createAuthHeader(); } String getUsername() { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java index 95e9b419075..9710ff90d92 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/JsonEncodedDockerRegistryAuthentication.java @@ -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); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransport.java index 695598a6ccc..01c9995f083 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransport.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransport.java @@ -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 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(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java index 0129de9d37d..3814b93ed47 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java @@ -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); } /** diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java index d83f203f953..9861f4d45a6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java @@ -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) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java index 8e81cce0d04..1655cc3f341 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java @@ -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 { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java index 3d8028d9e7f..cc1f6322213 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java @@ -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 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 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 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"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java index f2b91993620..0d5d100b3d5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java @@ -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 pullListener; + @Mock + private UpdateListener pushListener; + @Mock private UpdateListener 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())) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/LoadImageUpdateEventTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/LoadImageUpdateEventTests.java index e3cf8e6833b..40fa5a1e416 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/LoadImageUpdateEventTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/LoadImageUpdateEventTests.java @@ -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 { @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); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ProgressUpdateEventTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ProgressUpdateEventTests.java index df39aeb545e..480d0eeb85d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ProgressUpdateEventTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ProgressUpdateEventTests.java @@ -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 { @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); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEventTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEventTests.java index c3e2580704c..7cbe33a7dea 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEventTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PullImageUpdateEventTests.java @@ -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 { @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); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEventTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEventTests.java new file mode 100644 index 00000000000..913212055aa --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/PushImageUpdateEventTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.buildpack.platform.docker.ProgressUpdateEvent.ProgressDetail; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PushImageUpdateEvent}. + * + * @author Scott Frederick + */ +class PushImageUpdateEventTests extends ProgressUpdateEventTests { + + @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); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPullListenerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/TotalProgressListenerTests.java similarity index 65% rename from spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPullListenerTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/TotalProgressListenerTests.java index 3f15b1e3c8f..1dd2db0e3dc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/TotalProgressPullListenerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/TotalProgressListenerTests.java @@ -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 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 { - TestTotalProgressPullListener(Consumer consumer) { - super(consumer); + TestTotalProgressListener(Consumer 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); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationTests.java index 0c17ccdba57..fcb734df231 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationTests.java @@ -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; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java index 9b3bcb76aac..272dbeb0b58 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryTokenAuthenticationTests.java @@ -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); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java index aa1c46f8eea..d4720876877 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryUserAuthenticationTests.java @@ -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 { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransportTests.java index 24782778890..29d26e8a19d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransportTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransportTests.java @@ -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")); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java index 34f2226748c..acf3528fa38 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java @@ -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 hostOf(String scheme, String hostName, int port) { return (host) -> { assertThat(host).isNotNull(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream-with-error.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream-with-error.json new file mode 100644 index 00000000000..30ace62eedd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream-with-error.json @@ -0,0 +1,7 @@ +{ + "status":"The push refers to repository [localhost:5000/ubuntu]" +} +{"status":"Preparing","progressDetail":{},"id":"782f5f011dda"} +{"status":"Preparing","progressDetail":{},"id":"90ac32a0d9ab"} +{"status":"Preparing","progressDetail":{},"id":"d42a4fdf4b2a"} +{"errorDetail":{"message":"test message"},"error":"test error"} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream.json new file mode 100644 index 00000000000..2f9acafca7c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/push-stream.json @@ -0,0 +1,46 @@ +{ + "status":"The push refers to repository [localhost:5000/ubuntu]" +} +{"status":"Preparing","progressDetail":{},"id":"782f5f011dda"} +{"status":"Preparing","progressDetail":{},"id":"90ac32a0d9ab"} +{"status":"Preparing","progressDetail":{},"id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":512,"total":7},"progress":"[==================================================\u003e] 512B","id":"782f5f011dda"} +{"status":"Pushing","progressDetail":{"current":512,"total":811},"progress":"[===============================\u003e ] 512B/811B","id":"90ac32a0d9ab"} +{"status":"Pushing","progressDetail":{"current":3072,"total":7},"progress":"[==================================================\u003e] 3.072kB","id":"782f5f011dda"} +{"status":"Pushing","progressDetail":{"current":15360,"total":811},"progress":"[==================================================\u003e] 15.36kB","id":"90ac32a0d9ab"} +{"status":"Pushing","progressDetail":{"current":543232,"total":72874905},"progress":"[\u003e ] 543.2kB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushed","progressDetail":{},"id":"90ac32a0d9ab"} +{"status":"Pushed","progressDetail":{},"id":"782f5f011dda"} +{"status":"Pushing","progressDetail":{"current":2713600,"total":72874905},"progress":"[=\u003e ] 2.714MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":4870656,"total":72874905},"progress":"[===\u003e ] 4.871MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":7069184,"total":72874905},"progress":"[====\u003e ] 7.069MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":9238528,"total":72874905},"progress":"[======\u003e ] 9.239MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":11354112,"total":72874905},"progress":"[=======\u003e ] 11.35MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":13582336,"total":72874905},"progress":"[=========\u003e ] 13.58MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":16336248,"total":72874905},"progress":"[===========\u003e ] 16.34MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":19036160,"total":72874905},"progress":"[=============\u003e ] 19.04MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":21762560,"total":72874905},"progress":"[==============\u003e ] 21.76MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":24480256,"total":72874905},"progress":"[================\u003e ] 24.48MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":28756480,"total":72874905},"progress":"[===================\u003e ] 28.76MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":32001024,"total":72874905},"progress":"[=====================\u003e ] 32MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":34195456,"total":72874905},"progress":"[=======================\u003e ] 34.2MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":36393984,"total":72874905},"progress":"[========================\u003e ] 36.39MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":38587904,"total":72874905},"progress":"[==========================\u003e ] 38.59MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":41290752,"total":72874905},"progress":"[============================\u003e ] 41.29MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":43487744,"total":72874905},"progress":"[=============================\u003e ] 43.49MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":45683200,"total":72874905},"progress":"[===============================\u003e ] 45.68MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":48413184,"total":72874905},"progress":"[=================================\u003e ] 48.41MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":51119104,"total":72874905},"progress":"[===================================\u003e ] 51.12MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":53327360,"total":72874905},"progress":"[====================================\u003e ] 53.33MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":54964224,"total":72874905},"progress":"[=====================================\u003e ] 54.96MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":57169408,"total":72874905},"progress":"[=======================================\u003e ] 57.17MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":59355825,"total":72874905},"progress":"[========================================\u003e ] 59.36MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":62002592,"total":72874905},"progress":"[==========================================\u003e ] 62MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":64700928,"total":72874905},"progress":"[============================================\u003e ] 64.7MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":67435688,"total":72874905},"progress":"[==============================================\u003e ] 67.44MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":70095743,"total":72874905},"progress":"[================================================\u003e ] 70.1MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":72823808,"total":72874905},"progress":"[=================================================\u003e ] 72.82MB/72.87MB","id":"d42a4fdf4b2a"} +{"status":"Pushing","progressDetail":{"current":75247104,"total":72874905},"progress":"[==================================================\u003e] 75.25MB","id":"d42a4fdf4b2a"} +{"status":"Pushed","progressDetail":{},"id":"d42a4fdf4b2a"} +{"status":"latest: digest: sha256:2e70e9c81838224b5311970dbf7ed16802fbfe19e7a70b3cbfa3d7522aa285b4 size: 943"} +{"progressDetail":{},"aux":{"Tag":"latest","Digest":"sha256:2e70e9c81838224b5311970dbf7ed16802fbfe19e7a70b3cbfa3d7522aa285b4","Size":943}} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle index 8679086aff5..a3544fa203d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle @@ -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") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc index 70dd2f27606..d9458328d4c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc @@ -56,11 +56,14 @@ For more details, see also <>. [[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. -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. +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. -The following table summarizes the available 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 images for further information on supported authentication methods. + +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 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle index c91c53da9eb..923effbf713 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle @@ -10,7 +10,7 @@ bootJar { // tag::docker-auth-token[] bootBuildImage { docker { - registry { + builderRegistry { token = "9cbaf023786cd7..." } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle.kts index 2432601bf58..050c32b7eee 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle.kts +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-token.gradle.kts @@ -13,7 +13,7 @@ tasks.getByName("bootJar") { // tag::docker-auth-token[] tasks.getByName("bootBuildImage") { docker { - registry { + builderRegistry { token = "9cbaf023786cd7..." } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle index 41d6edb2aba..4f50a16c4b9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle @@ -10,7 +10,7 @@ bootJar { // tag::docker-auth-user[] bootBuildImage { docker { - registry { + builderRegistry { username = "user" password = "secret" url = "https://docker.example.com/v1/" diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle.kts index 7c469daa3be..cbef59ea053 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle.kts +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-auth-user.gradle.kts @@ -13,7 +13,7 @@ tasks.getByName("bootJar") { // tag::docker-auth-user[] tasks.getByName("bootBuildImage") { docker { - registry { + builderRegistry { username = "user" password = "secret" url = "https://docker.example.com/v1/" diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle new file mode 100644 index 00000000000..2bb6c81c37e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle @@ -0,0 +1,23 @@ +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[] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle.kts new file mode 100644 index 00000000000..f104a3033db --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-publish.gradle.kts @@ -0,0 +1,26 @@ +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") { + mainClassName = "com.example.ExampleApplication" +} + +// tag::publish[] +tasks.getByName("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[] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java index df8f6e3142f..0d89a11765d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java @@ -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() + ".*"; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java index 18aba086d5f..b1a4d133ca7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java @@ -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 getRegistry() { - return this.registry; + public DockerRegistrySpec getBuilderRegistry() { + return this.builderRegistry; } /** - * Customizes the {@link DockerRegistrySpec} that configures registry authentication. + * Customizes the {@link DockerRegistrySpec} that configures authentication to the + * builder registry. * @param action the action to apply */ - public void registry(Action action) { - action.execute(this.registry); + public void builderRegistry(Action action) { + action.execute(this.builderRegistry); } /** - * Customizes the {@link DockerRegistrySpec} that configures registry authentication. + * Customizes the {@link DockerRegistrySpec} that configures authentication to the + * builder registry. * @param closure the closure to apply */ - public void registry(Closure closure) { - registry(ConfigureUtil.configureUsing(closure)); + 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 getPublishRegistry() { + return this.publishRegistry; + } + + /** + * Customizes the {@link DockerRegistrySpec} that configures authentication to the + * publishing registry. + * @param action the action to apply + */ + public void publishRegistry(Action action) { + action.execute(this.publishRegistry); + } + + /** + * Customizes the {@link DockerRegistrySpec} that configures authentication to the + * publishing registry. + * @param closure the closure to apply + */ + 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 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java index fbbd62ce718..78603805b50 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java @@ -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(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.java new file mode 100644 index 00000000000..f3678363d23 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.java @@ -0,0 +1,112 @@ +/* + * 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() { + super("registry:2.7.1"); + addExposedPorts(5000); + addEnv("SERVER_NAME", "localhost"); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java index fad0ead78de..8d3159f0a92 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java @@ -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"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java index df41e740f65..b305b23b332 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java @@ -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)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle new file mode 100644 index 00000000000..3badc8b261a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle @@ -0,0 +1,17 @@ +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 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle index b07d59c232a..06d4a0f7196 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle @@ -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") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc index 4497c1e1dbc..4dc349b2091 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc @@ -79,11 +79,14 @@ For more details, see also <>. [[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. -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. +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. -The following table summarizes the available 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 images for further information on supported authentication methods. + +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"] +---- + + + + + org.springframework.boot + spring-boot-maven-plugin + {gradle-project-version} + + + docker.example.com/library/${project.artifactId} + true + + + + user + secret + https://docker.example.com/v1/ + user@example.com + + + + + + + +---- + +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 ---- -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 {gradle-project-version} - + user secret https://docker.example.com/v1/ user@example.com - + @@ -356,7 +406,7 @@ If the builder or run image are stored in a private Docker registry that support ---- -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 {gradle-project-version} - + 9cbaf023786cd7... - + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageRegistryIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageRegistryIntegrationTests.java new file mode 100644 index 00000000000..87b8e57c13f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageRegistryIntegrationTests.java @@ -0,0 +1,87 @@ +/* + * 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() { + super("registry:2.7.1"); + addExposedPorts(5000); + addEnv("SERVER_NAME", "localhost"); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java index 2cbb5c58b5c..e1a2cc193be 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java @@ -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") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/pom.xml new file mode 100644 index 00000000000..ee4fe387ed0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + org.springframework.boot.maven.it + build-image + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + build-image + + + + true + + + + user + secret + + + + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/src/main/java/org/test/SampleApplication.java new file mode 100644 index 00000000000..27259ff01ad --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-publish/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,28 @@ +/* + * 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" + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java index 9aa3acb6231..81c6b6beed6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java @@ -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 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); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java index 46c249d25a2..86dfa7eef6a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java @@ -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 registry authentication. - * @param registry the registry configuration + * Sets the {@link DockerRegistry} that configures authentication to the builder + * registry. + * @param builderRegistry the registry configuration */ - public void setRegistry(DockerRegistry registry) { - this.registry = registry; + public void setBuilderRegistry(DockerRegistry builderRegistry) { + this.builderRegistry = builderRegistry; + } + + DockerRegistry getPublishRegistry() { + return this.publishRegistry; + } + + /** + * Sets the {@link DockerRegistry} that configures authentication to the publishing + * registry. + * @param builderRegistry the registry configuration + */ + 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; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java index 71ce39d19f1..2be0d72b032 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java @@ -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 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; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java index c7f1d39e583..a4421af41b6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java @@ -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.setRegistry(dockerRegistry); + docker.setBuilderRegistry( + new Docker.DockerRegistry("user", null, "https://docker.example.com", "docker@example.com")); assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration) - .withMessageContaining("Invalid Docker registry configuration"); + .withMessageContaining("Invalid Docker builder registry configuration"); + } + + @Test + void asDockerConfigurationWithIncompletePublishUserAuthFails() { + Docker docker = new Docker(); + docker.setPublishRegistry( + new Docker.DockerRegistry("user", null, "https://docker.example.com", "docker@example.com")); + assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration) + .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)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java index cdf51630012..78527757836 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java @@ -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());