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 7fc7ee08c2d3..213745c254f5 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 @@ -31,6 +31,7 @@ * @author Phillip Webb * @author Scott Frederick * @author Andrey Shlykov + * @author Rafael Ceccone * @since 2.3.0 */ public abstract class AbstractBuildLog implements BuildLog { @@ -89,6 +90,12 @@ public void executedLifecycle(BuildRequest request) { log(); } + @Override + public void createdTag(ImageReference tag) { + log("Successfully created image tag '" + tag + "'"); + log(); + } + private String getDigest(Image image) { List digests = image.getDigests(); return (digests.isEmpty() ? "" : digests.get(0)); 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 6a88ea471f24..23958ac09b33 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 @@ -31,6 +31,7 @@ * @author Phillip Webb * @author Scott Frederick * @author Andrey Shlykov + * @author Rafael Ceccone * @since 2.3.0 * @see #toSystemOut() */ @@ -99,6 +100,12 @@ public interface BuildLog { */ void executedLifecycle(BuildRequest request); + /** + * Log that a tag has been created. + * @param tag the tag reference + */ + void createdTag(ImageReference tag); + /** * Factory method that returns a {@link BuildLog} the outputs to {@link System#out}. * @return a build log instance that logs to system out 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 93600e49e0ee..7293f41160a3 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 @@ -36,6 +36,7 @@ * @author Phillip Webb * @author Scott Frederick * @author Andrey Shlykov + * @author Rafael Ceccone * @since 2.3.0 */ public class BuildRequest { @@ -68,6 +69,8 @@ public class BuildRequest { private final List bindings; + private final List tags; + BuildRequest(ImageReference name, Function applicationContent) { Assert.notNull(name, "Name must not be null"); Assert.notNull(applicationContent, "ApplicationContent must not be null"); @@ -83,12 +86,13 @@ public class BuildRequest { this.creator = Creator.withVersion(""); this.buildpacks = Collections.emptyList(); this.bindings = Collections.emptyList(); + this.tags = Collections.emptyList(); } BuildRequest(ImageReference name, Function applicationContent, ImageReference builder, ImageReference runImage, Creator creator, Map env, boolean cleanCache, boolean verboseLogging, PullPolicy pullPolicy, boolean publish, List buildpacks, - List bindings) { + List bindings, List tags) { this.name = name; this.applicationContent = applicationContent; this.builder = builder; @@ -101,6 +105,7 @@ public class BuildRequest { this.publish = publish; this.buildpacks = buildpacks; this.bindings = bindings; + this.tags = tags; } /** @@ -112,7 +117,7 @@ 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.publish, - this.buildpacks, this.bindings); + this.buildpacks, this.bindings, this.tags); } /** @@ -123,7 +128,7 @@ public BuildRequest withBuilder(ImageReference builder) { 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.publish, - this.buildpacks, this.bindings); + this.buildpacks, this.bindings, this.tags); } /** @@ -134,7 +139,8 @@ public BuildRequest withRunImage(ImageReference runImageName) { 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.publish, this.buildpacks, this.bindings); + this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, + this.tags); } /** @@ -150,7 +156,7 @@ public BuildRequest withEnv(String name, String value) { 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, this.publish, - this.buildpacks, this.bindings); + this.buildpacks, this.bindings, this.tags); } /** @@ -164,7 +170,7 @@ public BuildRequest withEnv(Map 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, - this.publish, this.buildpacks, this.bindings); + this.publish, this.buildpacks, this.bindings, this.tags); } /** @@ -174,7 +180,8 @@ public BuildRequest withEnv(Map env) { */ 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, this.publish, this.buildpacks, this.bindings); + cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, + this.tags); } /** @@ -184,7 +191,8 @@ public BuildRequest withCleanCache(boolean cleanCache) { */ 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.publish, this.buildpacks, this.bindings); + this.cleanCache, verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, + this.tags); } /** @@ -194,7 +202,8 @@ public BuildRequest withVerboseLogging(boolean verboseLogging) { */ 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.publish, this.buildpacks, this.bindings); + this.cleanCache, this.verboseLogging, pullPolicy, this.publish, this.buildpacks, this.bindings, + this.tags); } /** @@ -204,7 +213,8 @@ public BuildRequest withPullPolicy(PullPolicy pullPolicy) { */ 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, this.buildpacks, this.bindings); + this.cleanCache, this.verboseLogging, this.pullPolicy, publish, this.buildpacks, this.bindings, + this.tags); } /** @@ -227,7 +237,8 @@ public BuildRequest withBuildpacks(BuildpackReference... buildpacks) { public BuildRequest withBuildpacks(List buildpacks) { Assert.notNull(buildpacks, "Buildpacks must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, - this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, buildpacks, this.bindings); + this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, buildpacks, this.bindings, + this.tags); } /** @@ -250,7 +261,30 @@ public BuildRequest withBindings(Binding... bindings) { public BuildRequest withBindings(List bindings) { Assert.notNull(bindings, "Bindings must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, - this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, bindings); + this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, bindings, + this.tags); + } + + /** + * Return a new {@link BuildRequest} with updated tags. + * @param tags a collection of tags to be created for the built image + * @return an updated build request + */ + public BuildRequest withTags(ImageReference... tags) { + Assert.notEmpty(tags, "Tags must not be empty"); + return withTags(Arrays.asList(tags)); + } + + /** + * Return a new {@link BuildRequest} with updated tags. + * @param tags a collection of tags to be created for the built image + * @return an updated build request + */ + public BuildRequest withTags(List tags) { + Assert.notNull(tags, "Tags must not be null"); + return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, + this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, + tags); } /** @@ -353,6 +387,14 @@ public List getBindings() { return this.bindings; } + /** + * Return the collection of tags that should be created. + * @return the tags + */ + public List getTags() { + return this.tags; + } + /** * Factory method to create a new {@link BuildRequest} from a JAR file. * @param jarFile the source jar file 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 e3c21e0f5bae..a666cfe5f5c0 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 @@ -41,6 +41,7 @@ * @author Phillip Webb * @author Scott Frederick * @author Andrey Shlykov + * @author Rafael Ceccone * @since 2.3.0 */ public class Builder { @@ -110,8 +111,10 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio this.docker.image().load(ephemeralBuilder.getArchive(), UpdateListener.none()); try { executeLifecycle(request, ephemeralBuilder); + createTags(request.getName(), request.getTags()); if (request.isPublish()) { pushImage(request.getName()); + pushTags(request.getTags()); } } finally { @@ -157,6 +160,19 @@ private void pushImage(ImageReference reference) throws IOException { this.log.pushedImage(reference); } + private void createTags(ImageReference sourceReference, List tags) throws IOException { + for (ImageReference tag : tags) { + this.docker.image().tag(sourceReference, tag); + this.log.createdTag(tag); + } + } + + private void pushTags(List tags) throws IOException { + for (ImageReference tag : tags) { + pushImage(tag); + } + } + private String getBuilderAuthHeader() { return (this.dockerConfiguration != null && this.dockerConfiguration.getBuilderRegistryAuthentication() != null) ? this.dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader() : null; 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 c07523d5440f..24d83d70c398 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 @@ -52,6 +52,7 @@ * * @author Phillip Webb * @author Scott Frederick + * @author Rafael Ceccone * @since 2.3.0 */ public class DockerApi { @@ -300,6 +301,13 @@ public Image inspect(ImageReference reference) throws IOException { } } + public void tag(ImageReference sourceReference, ImageReference targetReference) throws IOException { + Assert.notNull(sourceReference, "SourceReference must not be null"); + Assert.notNull(targetReference, "TargetReference must not be null"); + URI uri = buildUrl("/images/" + sourceReference + "/tag", "repo", targetReference.toString()); + http().post(uri); + } + } /** diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java index 92115cc0a594..3d3dec4dc94e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java @@ -45,6 +45,7 @@ * * @author Phillip Webb * @author Scott Frederick + * @author Rafael Ceccone */ public class BuildRequestTests { @@ -199,6 +200,25 @@ void withBindingsWhenBindingsIsNullThrowsException() throws IOException { .withMessage("Bindings must not be null"); } + @Test + void withTagsAddsTags() throws IOException { + BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); + BuildRequest witTags = request.withTags(ImageReference.of("docker.io/library/my-app:latest"), + ImageReference.of("example.com/custom/my-app:0.0.1"), + ImageReference.of("example.com/custom/my-app:latest")); + assertThat(request.getTags()).isEmpty(); + assertThat(witTags.getTags()).containsExactly(ImageReference.of("docker.io/library/my-app:latest"), + ImageReference.of("example.com/custom/my-app:0.0.1"), + ImageReference.of("example.com/custom/my-app:latest")); + } + + @Test + void withTagsWhenTagsIsNullThrowsException() throws IOException { + BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); + assertThatIllegalArgumentException().isThrownBy(() -> request.withTags((List) null)) + .withMessage("Tags must not be null"); + } + private void hasExpectedJarContent(TarArchive archive) { try { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 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 8e17071958cc..38bef618b60b 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 @@ -58,6 +58,7 @@ * * @author Phillip Webb * @author Scott Frederick + * @author Rafael Ceccone */ class BuilderTests { @@ -276,6 +277,65 @@ void buildInvokesBuilderWithIfNotPresentPullPolicy() throws Exception { verify(docker.image(), times(2)).pull(any(), any(), isNull()); } + @Test + void buildInvokesBuilderWithTags() throws Exception { + TestPrintStream out = new TestPrintStream(); + 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(), isNull())) + .willAnswer(withPulledImage(builderImage)); + 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, null); + BuildRequest request = getTestRequest().withTags(ImageReference.of("my-application:1.2.3")); + builder.build(request); + assertThat(out.toString()).contains("Running creator"); + assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); + assertThat(out.toString()).contains("Successfully created image tag 'docker.io/library/my-application:1.2.3'"); + verify(docker.image()).tag(eq(request.getName()), eq(ImageReference.of("my-application:1.2.3"))); + ArgumentCaptor archive = ArgumentCaptor.forClass(ImageArchive.class); + verify(docker.image()).load(archive.capture(), any()); + verify(docker.image()).remove(archive.getValue().getTag(), true); + } + + @Test + void buildInvokesBuilderWithTagsAndPublishesImageAndTags() 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).withTags(ImageReference.of("my-application:1.2.3")); + builder.build(request); + assertThat(out.toString()).contains("Running creator"); + assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); + assertThat(out.toString()).contains("Successfully created image tag 'docker.io/library/my-application:1.2.3'"); + + 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()).tag(eq(request.getName()), eq(ImageReference.of("my-application:1.2.3"))); + verify(docker.image()).push(eq(ImageReference.of("my-application:1.2.3")), any(), + eq(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())); + ArgumentCaptor archive = ArgumentCaptor.forClass(ImageArchive.class); + verify(docker.image()).load(archive.capture(), any()); + verify(docker.image()).remove(archive.getValue().getTag(), true); + verifyNoMoreInteractions(docker.image()); + } + @Test void buildWhenStackIdDoesNotMatchThrowsException() throws Exception { TestPrintStream out = new TestPrintStream(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java index ec0ec4f3f75b..a20e94b88e7b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 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. @@ -40,6 +40,7 @@ * Tests for {@link PrintStreamBuildLog}. * * @author Phillip Webb + * @author Rafael Ceccone */ class PrintStreamBuildLogTests { @@ -56,6 +57,8 @@ void printsExpectedOutput() throws Exception { Image runImage = mock(Image.class); given(runImage.getDigests()).willReturn(Collections.singletonList("00000002")); given(request.getName()).willReturn(name); + ImageReference tag = ImageReference.of("my-app:1.0"); + given(request.getTags()).willReturn(Collections.singletonList(tag)); log.start(request); Consumer pullBuildImageConsumer = log.pullingImage(builderImageReference, ImageType.BUILDER); @@ -73,6 +76,7 @@ void printsExpectedOutput() throws Exception { phase2Consumer.accept(mockLogEvent("spring")); phase2Consumer.accept(mockLogEvent("boot")); log.executedLifecycle(request); + log.createdTag(tag); String expected = FileCopyUtils.copyToString(new InputStreamReader( getClass().getResourceAsStream("print-stream-build-log.txt"), StandardCharsets.UTF_8)); assertThat(out.toString()).isEqualToIgnoringNewLines(expected); 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 912d5af97556..3270e041bb6b 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 @@ -71,6 +71,7 @@ * * @author Phillip Webb * @author Scott Frederick + * @author Rafael Ceccone */ @ExtendWith(MockitoExtension.class) class DockerApiTests { @@ -348,6 +349,30 @@ void exportLayersExportsLayerTars() throws Exception { .containsExactly("etc/", "etc/apt/", "etc/apt/sources.list"); } + @Test + void tagWhenReferenceIsNullThrowsException() { + ImageReference tag = ImageReference.of("localhost:5000/ubuntu"); + assertThatIllegalArgumentException().isThrownBy(() -> this.api.tag(null, tag)) + .withMessage("SourceReference must not be null"); + } + + @Test + void tagWhenTargetIsNullThrowsException() { + ImageReference reference = ImageReference.of("localhost:5000/ubuntu"); + assertThatIllegalArgumentException().isThrownBy(() -> this.api.tag(reference, null)) + .withMessage("TargetReference must not be null"); + } + + @Test + void tagTagsImage() throws Exception { + ImageReference sourceReference = ImageReference.of("localhost:5000/ubuntu"); + ImageReference targetReference = ImageReference.of("localhost:5000/ubuntu:tagged"); + URI tagURI = new URI(IMAGES_URL + "/localhost:5000/ubuntu/tag?repo=localhost%3A5000%2Fubuntu%3Atagged"); + given(http().post(tagURI)).willReturn(emptyResponse()); + this.api.tag(sourceReference, targetReference); + verify(http()).post(tagURI); + } + } @Nested diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/print-stream-build-log.txt b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/print-stream-build-log.txt index 83cfdffd0a56..6fcfc7ee2c01 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/print-stream-build-log.txt +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/print-stream-build-log.txt @@ -17,3 +17,5 @@ Building image 'docker.io/library/my-app:latest' [basket] boot Successfully built image 'docker.io/library/my-app:latest' + +Successfully created image tag 'docker.io/library/my-app:1.0' 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 65f0f4cc6db2..79eec7ac5119 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 @@ -169,6 +169,11 @@ Where `` can contain: | `--publishImage` | Whether to publish the generated image to a Docker registry. | `false` + +| `tags` +| +| Multiple {spring-boot-api}/buildpack/platform/docker/type/ImageReference.html#of-java.lang.String-[tag names] to be created for the generated image. +| |=== NOTE: The plugin detects the target Java compatibility of the project using the JavaPlugin's `targetCompatibility` property. 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 bdd8cdb2e231..fc9115da3477 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 @@ -58,6 +58,7 @@ * * @author Andy Wilkinson * @author Scott Frederick + * @author Rafael Ceccone * @since 2.3.0 */ public class BootBuildImage extends DefaultTask { @@ -92,6 +93,8 @@ public class BootBuildImage extends DefaultTask { private final ListProperty bindings; + private final ListProperty tags; + private final DockerSpec docker = new DockerSpec(); public BootBuildImage() { @@ -103,6 +106,7 @@ public BootBuildImage() { this.projectVersion.set(getProject().provider(() -> project.getVersion().toString())); this.buildpacks = getProject().getObjects().listProperty(String.class); this.bindings = getProject().getObjects().listProperty(String.class); + this.tags = getProject().getObjects().listProperty(String.class); } /** @@ -376,6 +380,40 @@ public void bindings(List bindings) { this.bindings.addAll(bindings); } + /** + * Returns the tags that will be created for the built image. + * @return the tags + */ + @Input + @Optional + public List getTags() { + return this.tags.getOrNull(); + } + + /** + * Sets the tags that will be created for the built image. + * @param tags the tags + */ + public void setTags(List tags) { + this.tags.set(tags); + } + + /** + * Add an entry to the tags that will be created for the built image. + * @param tag the tag + */ + public void tag(String tag) { + this.tags.add(tag); + } + + /** + * Add entries to the tags that will be created for the built image. + * @param tags the tags + */ + public void tags(List tags) { + this.tags.addAll(tags); + } + /** * Returns the Docker configuration the builder will use. * @return docker configuration. @@ -438,6 +476,7 @@ private BuildRequest customize(BuildRequest request) { request = customizePublish(request); request = customizeBuildpacks(request); request = customizeBindings(request); + request = customizeTags(request); return request; } @@ -506,6 +545,14 @@ private BuildRequest customizeBindings(BuildRequest request) { return request; } + private BuildRequest customizeTags(BuildRequest request) { + List tags = this.tags.getOrNull(); + if (tags != null && !tags.isEmpty()) { + return request.withTags(tags.stream().map(ImageReference::of).collect(Collectors.toList())); + } + return request; + } + private String translateTargetJavaVersion() { return this.targetJavaVersion.get().getMajorVersion() + ".*"; } 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 a8517e11ef67..0a3efc1c1bfd 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 @@ -40,7 +40,6 @@ import org.junit.jupiter.api.condition.OS; import org.springframework.boot.buildpack.platform.docker.DockerApi; -import org.springframework.boot.buildpack.platform.docker.type.ImageName; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.FilePermissions; import org.springframework.boot.gradle.junit.GradleCompatibility; @@ -54,6 +53,7 @@ * * @author Andy Wilkinson * @author Scott Frederick + * @author Rafael Ceccone */ @GradleCompatibility(configurationCache = true) @DisabledIfDockerUnavailable @@ -234,6 +234,21 @@ void buildsImageWithBinding() throws IOException { removeImage(projectName); } + @TestTemplate + void buildsImageWithTag() throws IOException { + writeMainClass(); + writeLongNameResource(); + BuildResult result = this.gradleBuild.build("bootBuildImage", "--pullPolicy=IF_NOT_PRESENT"); + String projectName = this.gradleBuild.getProjectDir().getName(); + assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).contains("docker.io/library/" + projectName); + assertThat(result.getOutput()).contains("---> Test Info buildpack building"); + assertThat(result.getOutput()).contains("---> Test Info buildpack done"); + assertThat(result.getOutput()).contains("example.com/myapp:latest"); + removeImage(projectName); + removeImage("example.com/myapp:latest"); + } + @TestTemplate void buildsImageWithLaunchScript() throws IOException { writeMainClass(); @@ -285,6 +300,16 @@ void failsWithBuildpackNotInBuilder() throws IOException { assertThat(result.getOutput()).contains("'urn:cnb:builder:example/does-not-exist:0.0.1' not found in builder"); } + @TestTemplate + void failsWithInvalidTagName() throws IOException { + writeMainClass(); + writeLongNameResource(); + BuildResult result = this.gradleBuild.buildAndFail("bootBuildImage", "--pullPolicy=IF_NOT_PRESENT"); + assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.FAILED); + assertThat(result.getOutput()).containsPattern("Unable to parse image reference") + .containsPattern("example/Invalid-Tag-Name"); + } + private void writeMainClass() throws IOException { File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example"); examplePackage.mkdirs(); @@ -408,7 +433,7 @@ private void writeCertificateBindingFiles() throws IOException { } private void removeImage(String name) throws IOException { - ImageReference imageReference = ImageReference.of(ImageName.of(name)); + ImageReference imageReference = ImageReference.of(name); new DockerApi().image().remove(imageReference, false); } 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 31d2d10dd8a8..896bfbeb34b7 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 @@ -31,6 +31,7 @@ import org.springframework.boot.buildpack.platform.build.BuildpackReference; import org.springframework.boot.buildpack.platform.build.PullPolicy; import org.springframework.boot.buildpack.platform.docker.type.Binding; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.gradle.junit.GradleProjectBuilder; import static org.assertj.core.api.Assertions.assertThat; @@ -42,6 +43,7 @@ * @author Andy Wilkinson * @author Scott Frederick * @author Andrey Shlykov + * @author Rafael Ceccone */ class BootBuildImageTests { @@ -278,4 +280,34 @@ void whenIndividualEntriesAreAddedToBindingsThenRequestHasBindings() { .containsExactly(Binding.of("host-src:container-dest:ro"), Binding.of("volume-name:container-dest:rw")); } + @Test + void whenNoTagsAreConfiguredThenRequestHasNoTags() { + assertThat(this.buildImage.createRequest().getTags()).isEmpty(); + } + + @Test + void whenTagsAreConfiguredThenRequestHasTags() { + this.buildImage.setTags( + Arrays.asList("my-app:latest", "example.com/my-app:0.0.1-SNAPSHOT", "example.com/my-app:latest")); + assertThat(this.buildImage.createRequest().getTags()).containsExactly(ImageReference.of("my-app:latest"), + ImageReference.of("example.com/my-app:0.0.1-SNAPSHOT"), ImageReference.of("example.com/my-app:latest")); + } + + @Test + void whenEntriesAreAddedToTagsThenRequestHasTags() { + this.buildImage + .tags(Arrays.asList("my-app:latest", "example.com/my-app:0.0.1-SNAPSHOT", "example.com/my-app:latest")); + assertThat(this.buildImage.createRequest().getTags()).containsExactly(ImageReference.of("my-app:latest"), + ImageReference.of("example.com/my-app:0.0.1-SNAPSHOT"), ImageReference.of("example.com/my-app:latest")); + } + + @Test + void whenIndividualEntriesAreAddedToTagsThenRequestHasTags() { + this.buildImage.tag("my-app:latest"); + this.buildImage.tag("example.com/my-app:0.0.1-SNAPSHOT"); + this.buildImage.tag("example.com/my-app:latest"); + assertThat(this.buildImage.createRequest().getTags()).containsExactly(ImageReference.of("my-app:latest"), + ImageReference.of("example.com/my-app:0.0.1-SNAPSHOT"), ImageReference.of("example.com/my-app:latest")); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithTag.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithTag.gradle new file mode 100644 index 000000000000..198699e7fe45 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithTag.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +sourceCompatibility = '1.8' +targetCompatibility = '1.8' + +bootBuildImage { + builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.1" + tags = [ "example.com/myapp:latest" ] +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithInvalidTagName.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithInvalidTagName.gradle new file mode 100644 index 000000000000..3c350dd1fbba --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithInvalidTagName.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +sourceCompatibility = '1.8' +targetCompatibility = '1.8' + +bootBuildImage { + builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.1" + tags = [ "example/Invalid-Tag-Name" ] +} 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 24de20aaa1a6..50128b11d159 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 @@ -176,6 +176,10 @@ Where `` can contain: (`spring-boot.build-image.publish`) | Whether to publish the generated image to a Docker registry. | `false` + +| `tags` +| Multiple {spring-boot-api}/buildpack/platform/docker/type/ImageReference.html#of-java.lang.String-[tag names] to be created for the generated image. +| |=== NOTE: The plugin detects the target Java compatibility of the project using the compiler's plugin configuration or the `maven.compiler.target` property. 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 6bc3d5677025..fe84a29e9366 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 @@ -38,6 +38,7 @@ * * @author Stephane Nicoll * @author Scott Frederick + * @author Rafael Ceccone */ @ExtendWith(MavenBuildExtension.class) @DisabledIfDockerUnavailable @@ -281,6 +282,19 @@ void whenBuildImageIsInvokedOnMultiModuleProjectWithPackageGoal(MavenBuild maven }); } + @TestTemplate + void whenBuildImageIsInvokedWithTags(MavenBuild mavenBuild) { + mavenBuild.project("build-image-tags").goals("package") + .systemProperty("spring-boot.build-image.pullPolicy", "IF_NOT_PRESENT").execute((project) -> { + assertThat(buildLog(project)).contains("Building image") + .contains("docker.io/library/build-image-tags:0.0.1.BUILD-SNAPSHOT") + .contains("Successfully built image").contains("docker.io/library/build-image-tags:latest") + .contains("Successfully created image tag"); + removeImage("build-image-tags", "0.0.1.BUILD-SNAPSHOT"); + removeImage("build-image-tags", "latest"); + }); + } + @TestTemplate void failsWhenBuildImageIsInvokedOnMultiModuleProjectWithBuildImageGoal(MavenBuild mavenBuild) { mavenBuild.project("build-image-multi-module").goals("spring-boot:build-image") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-tags/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-tags/pom.xml new file mode 100644 index 000000000000..61e478cdd346 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-tags/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + org.springframework.boot.maven.it + build-image-tags + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + build-image + + + + projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.1 + + ${project.artifactId}:latest + + + + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-tags/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-tags/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..e964724deacd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-tags/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2012-2021 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/Image.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java index 0ce0afd83b49..60a58ba4db97 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 @@ -39,6 +39,7 @@ * * @author Phillip Webb * @author Scott Frederick + * @author Rafael Ceccone * @since 2.3.0 */ public class Image { @@ -63,6 +64,8 @@ public class Image { List bindings; + List tags; + /** * The name of the created image. * @return the image name @@ -190,6 +193,9 @@ private BuildRequest customize(BuildRequest request) { if (!CollectionUtils.isEmpty(this.bindings)) { request = request.withBindings(this.bindings.stream().map(Binding::of).collect(Collectors.toList())); } + if (!CollectionUtils.isEmpty(this.tags)) { + request = request.withTags(this.tags.stream().map(ImageReference::of).collect(Collectors.toList())); + } return request; } 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 15ee4db41051..24137b0b7440 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 @@ -30,6 +30,7 @@ import org.springframework.boot.buildpack.platform.build.BuildpackReference; import org.springframework.boot.buildpack.platform.build.PullPolicy; import org.springframework.boot.buildpack.platform.docker.type.Binding; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.Owner; import org.springframework.boot.buildpack.platform.io.TarArchive; @@ -41,6 +42,7 @@ * * @author Phillip Webb * @author Scott Frederick + * @author Rafael Ceccone */ class ImageTests { @@ -146,6 +148,15 @@ void getBuildRequestWhenHasBindingsUsesBindings() { Binding.of("volume-name:container-dest:rw")); } + @Test + void getBuildRequestWhenHasTagsUsesTags() { + Image image = new Image(); + image.tags = Arrays.asList("my-app:latest", "example.com/my-app:0.0.1-SNAPSHOT", "example.com/my-app:latest"); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getTags()).containsExactly(ImageReference.of("my-app:latest"), + ImageReference.of("example.com/my-app:0.0.1-SNAPSHOT"), ImageReference.of("example.com/my-app:latest")); + } + private Artifact createArtifact() { return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile", "jar", null, new DefaultArtifactHandler());