Add option to create tags for a built image

This commit adds configuration to the Maven and Gradle plugins to
allow specifying multiple tag to be created that refer to the built
image.

See gh-27613
pull/28151/head
Rafael Ceccone 3 years ago committed by Scott Frederick
parent ca69c8b98c
commit 66f44b0c7f

@ -31,6 +31,7 @@ import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Andrey Shlykov * @author Andrey Shlykov
* @author Rafael Ceccone
* @since 2.3.0 * @since 2.3.0
*/ */
public abstract class AbstractBuildLog implements BuildLog { public abstract class AbstractBuildLog implements BuildLog {
@ -89,6 +90,12 @@ public abstract class AbstractBuildLog implements BuildLog {
log(); log();
} }
@Override
public void createdTag(ImageReference tag) {
log("Successfully created image tag '" + tag + "'");
log();
}
private String getDigest(Image image) { private String getDigest(Image image) {
List<String> digests = image.getDigests(); List<String> digests = image.getDigests();
return (digests.isEmpty() ? "" : digests.get(0)); return (digests.isEmpty() ? "" : digests.get(0));

@ -31,6 +31,7 @@ import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Andrey Shlykov * @author Andrey Shlykov
* @author Rafael Ceccone
* @since 2.3.0 * @since 2.3.0
* @see #toSystemOut() * @see #toSystemOut()
*/ */
@ -99,6 +100,12 @@ public interface BuildLog {
*/ */
void executedLifecycle(BuildRequest request); 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}. * Factory method that returns a {@link BuildLog} the outputs to {@link System#out}.
* @return a build log instance that logs to system out * @return a build log instance that logs to system out

@ -37,6 +37,7 @@ import org.springframework.util.Assert;
* @author Scott Frederick * @author Scott Frederick
* @author Andrey Shlykov * @author Andrey Shlykov
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Rafael Ceccone
* @since 2.3.0 * @since 2.3.0
*/ */
public class BuildRequest { public class BuildRequest {
@ -71,6 +72,8 @@ public class BuildRequest {
private final String network; private final String network;
private final List<ImageReference> tags;
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) { BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) {
Assert.notNull(name, "Name must not be null"); Assert.notNull(name, "Name must not be null");
Assert.notNull(applicationContent, "ApplicationContent must not be null"); Assert.notNull(applicationContent, "ApplicationContent must not be null");
@ -87,12 +90,13 @@ public class BuildRequest {
this.buildpacks = Collections.emptyList(); this.buildpacks = Collections.emptyList();
this.bindings = Collections.emptyList(); this.bindings = Collections.emptyList();
this.network = null; this.network = null;
this.tags = Collections.emptyList();
} }
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder, BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder,
ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache, ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache,
boolean verboseLogging, PullPolicy pullPolicy, boolean publish, List<BuildpackReference> buildpacks, boolean verboseLogging, PullPolicy pullPolicy, boolean publish, List<BuildpackReference> buildpacks,
List<Binding> bindings, String network) { List<Binding> bindings, String network, List<ImageReference> tags) {
this.name = name; this.name = name;
this.applicationContent = applicationContent; this.applicationContent = applicationContent;
this.builder = builder; this.builder = builder;
@ -106,6 +110,7 @@ public class BuildRequest {
this.buildpacks = buildpacks; this.buildpacks = buildpacks;
this.bindings = bindings; this.bindings = bindings;
this.network = network; this.network = network;
this.tags = tags;
} }
/** /**
@ -117,7 +122,7 @@ public class BuildRequest {
Assert.notNull(builder, "Builder must not be null"); Assert.notNull(builder, "Builder must not be null");
return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage, return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage,
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish,
this.buildpacks, this.bindings, this.network); this.buildpacks, this.bindings, this.network, this.tags);
} }
/** /**
@ -128,7 +133,7 @@ public class BuildRequest {
public BuildRequest withRunImage(ImageReference runImageName) { public BuildRequest withRunImage(ImageReference runImageName) {
return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(), return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(),
this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish,
this.buildpacks, this.bindings, this.network); this.buildpacks, this.bindings, this.network, this.tags);
} }
/** /**
@ -140,7 +145,7 @@ public class BuildRequest {
Assert.notNull(creator, "Creator must not be null"); Assert.notNull(creator, "Creator must not be null");
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
this.network); this.network, this.tags);
} }
/** /**
@ -156,7 +161,7 @@ public class BuildRequest {
env.put(name, value); env.put(name, value);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish,
this.buildpacks, this.bindings, this.network); this.buildpacks, this.bindings, this.network, this.tags);
} }
/** /**
@ -170,7 +175,7 @@ public class BuildRequest {
updatedEnv.putAll(env); updatedEnv.putAll(env);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy, Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy,
this.publish, this.buildpacks, this.bindings, this.network); this.publish, this.buildpacks, this.bindings, this.network, this.tags);
} }
/** /**
@ -181,7 +186,7 @@ public class BuildRequest {
public BuildRequest withCleanCache(boolean cleanCache) { public BuildRequest withCleanCache(boolean cleanCache) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
this.network); this.network, this.tags);
} }
/** /**
@ -192,7 +197,7 @@ public class BuildRequest {
public BuildRequest withVerboseLogging(boolean verboseLogging) { public BuildRequest withVerboseLogging(boolean verboseLogging) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, this.cleanCache, verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
this.network); this.network, this.tags);
} }
/** /**
@ -203,7 +208,7 @@ public class BuildRequest {
public BuildRequest withPullPolicy(PullPolicy pullPolicy) { public BuildRequest withPullPolicy(PullPolicy pullPolicy) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, pullPolicy, this.publish, this.buildpacks, this.bindings, this.cleanCache, this.verboseLogging, pullPolicy, this.publish, this.buildpacks, this.bindings,
this.network); this.network, this.tags);
} }
/** /**
@ -214,7 +219,7 @@ public class BuildRequest {
public BuildRequest withPublish(boolean publish) { public BuildRequest withPublish(boolean publish) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy, publish, this.buildpacks, this.bindings, this.cleanCache, this.verboseLogging, this.pullPolicy, publish, this.buildpacks, this.bindings,
this.network); this.network, this.tags);
} }
/** /**
@ -238,7 +243,7 @@ public class BuildRequest {
Assert.notNull(buildpacks, "Buildpacks must not be null"); Assert.notNull(buildpacks, "Buildpacks must not be null");
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, buildpacks, this.bindings, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, buildpacks, this.bindings,
this.network); this.network, this.tags);
} }
/** /**
@ -262,7 +267,7 @@ public class BuildRequest {
Assert.notNull(bindings, "Bindings must not be null"); Assert.notNull(bindings, "Bindings must not be null");
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, bindings, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, bindings,
this.network); this.network, this.tags);
} }
/** /**
@ -274,7 +279,29 @@ public class BuildRequest {
public BuildRequest withNetwork(String network) { public BuildRequest withNetwork(String network) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
network); network, 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<ImageReference> 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,
this.network, tags);
} }
/** /**
@ -386,6 +413,14 @@ public class BuildRequest {
return this.network; return this.network;
} }
/**
* Return the collection of tags that should be created.
* @return the tags
*/
public List<ImageReference> getTags() {
return this.tags;
}
/** /**
* Factory method to create a new {@link BuildRequest} from a JAR file. * Factory method to create a new {@link BuildRequest} from a JAR file.
* @param jarFile the source jar file * @param jarFile the source jar file

@ -41,6 +41,7 @@ import org.springframework.util.StringUtils;
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Andrey Shlykov * @author Andrey Shlykov
* @author Rafael Ceccone
* @since 2.3.0 * @since 2.3.0
*/ */
public class Builder { public class Builder {
@ -110,8 +111,10 @@ public class Builder {
this.docker.image().load(ephemeralBuilder.getArchive(), UpdateListener.none()); this.docker.image().load(ephemeralBuilder.getArchive(), UpdateListener.none());
try { try {
executeLifecycle(request, ephemeralBuilder); executeLifecycle(request, ephemeralBuilder);
createTags(request.getName(), request.getTags());
if (request.isPublish()) { if (request.isPublish()) {
pushImage(request.getName()); pushImage(request.getName());
pushTags(request.getTags());
} }
} }
finally { finally {
@ -157,6 +160,19 @@ public class Builder {
this.log.pushedImage(reference); this.log.pushedImage(reference);
} }
private void createTags(ImageReference sourceReference, List<ImageReference> tags) throws IOException {
for (ImageReference tag : tags) {
this.docker.image().tag(sourceReference, tag);
this.log.createdTag(tag);
}
}
private void pushTags(List<ImageReference> tags) throws IOException {
for (ImageReference tag : tags) {
pushImage(tag);
}
}
private String getBuilderAuthHeader() { private String getBuilderAuthHeader() {
return (this.dockerConfiguration != null && this.dockerConfiguration.getBuilderRegistryAuthentication() != null) return (this.dockerConfiguration != null && this.dockerConfiguration.getBuilderRegistryAuthentication() != null)
? this.dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader() : null; ? this.dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader() : null;

@ -52,6 +52,7 @@ import org.springframework.util.StringUtils;
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Rafael Ceccone
* @since 2.3.0 * @since 2.3.0
*/ */
public class DockerApi { public class DockerApi {
@ -300,6 +301,13 @@ public class DockerApi {
} }
} }
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);
}
} }
/** /**

@ -46,6 +46,7 @@ import static org.assertj.core.api.Assertions.entry;
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Rafael Ceccone
*/ */
class BuildRequestTests { class BuildRequestTests {
@ -206,6 +207,25 @@ class BuildRequestTests {
assertThat(request.getNetwork()).isEqualTo("test"); assertThat(request.getNetwork()).isEqualTo("test");
} }
@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<ImageReference>) null))
.withMessage("Tags must not be null");
}
private void hasExpectedJarContent(TarArchive archive) { private void hasExpectedJarContent(TarArchive archive) {
try { try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

@ -58,6 +58,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Rafael Ceccone
*/ */
class BuilderTests { class BuilderTests {
@ -276,6 +277,65 @@ class BuilderTests {
verify(docker.image(), times(2)).pull(any(), any(), isNull()); 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<ImageArchive> 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<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
verifyNoMoreInteractions(docker.image());
}
@Test @Test
void buildWhenStackIdDoesNotMatchThrowsException() throws Exception { void buildWhenStackIdDoesNotMatchThrowsException() throws Exception {
TestPrintStream out = new TestPrintStream(); TestPrintStream out = new TestPrintStream();

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -40,6 +40,7 @@ import static org.mockito.Mockito.mock;
* Tests for {@link PrintStreamBuildLog}. * Tests for {@link PrintStreamBuildLog}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Rafael Ceccone
*/ */
class PrintStreamBuildLogTests { class PrintStreamBuildLogTests {
@ -56,6 +57,8 @@ class PrintStreamBuildLogTests {
Image runImage = mock(Image.class); Image runImage = mock(Image.class);
given(runImage.getDigests()).willReturn(Collections.singletonList("00000002")); given(runImage.getDigests()).willReturn(Collections.singletonList("00000002"));
given(request.getName()).willReturn(name); given(request.getName()).willReturn(name);
ImageReference tag = ImageReference.of("my-app:1.0");
given(request.getTags()).willReturn(Collections.singletonList(tag));
log.start(request); log.start(request);
Consumer<TotalProgressEvent> pullBuildImageConsumer = log.pullingImage(builderImageReference, Consumer<TotalProgressEvent> pullBuildImageConsumer = log.pullingImage(builderImageReference,
ImageType.BUILDER); ImageType.BUILDER);
@ -73,6 +76,7 @@ class PrintStreamBuildLogTests {
phase2Consumer.accept(mockLogEvent("spring")); phase2Consumer.accept(mockLogEvent("spring"));
phase2Consumer.accept(mockLogEvent("boot")); phase2Consumer.accept(mockLogEvent("boot"));
log.executedLifecycle(request); log.executedLifecycle(request);
log.createdTag(tag);
String expected = FileCopyUtils.copyToString(new InputStreamReader( String expected = FileCopyUtils.copyToString(new InputStreamReader(
getClass().getResourceAsStream("print-stream-build-log.txt"), StandardCharsets.UTF_8)); getClass().getResourceAsStream("print-stream-build-log.txt"), StandardCharsets.UTF_8));
assertThat(out.toString()).isEqualToIgnoringNewLines(expected); assertThat(out.toString()).isEqualToIgnoringNewLines(expected);

@ -71,6 +71,7 @@ import static org.mockito.Mockito.verify;
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Rafael Ceccone
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class DockerApiTests { class DockerApiTests {
@ -348,6 +349,30 @@ class DockerApiTests {
.containsExactly("etc/", "etc/apt/", "etc/apt/sources.list"); .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 @Nested

@ -17,3 +17,5 @@ Building image 'docker.io/library/my-app:latest'
[basket] boot [basket] boot
Successfully built image 'docker.io/library/my-app:latest' Successfully built image 'docker.io/library/my-app:latest'
Successfully created image tag 'docker.io/library/my-app:1.0'

@ -176,6 +176,11 @@ The value supplied will be passed unvalidated to Docker when creating the builde
| Whether to publish the generated image to a Docker registry. | Whether to publish the generated image to a Docker registry.
| `false` | `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. NOTE: The plugin detects the target Java compatibility of the project using the JavaPlugin's `targetCompatibility` property.

@ -58,6 +58,7 @@ import org.springframework.util.StringUtils;
* *
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Scott Frederick * @author Scott Frederick
* @author Rafael Ceccone
* @author Jeroen Meijer * @author Jeroen Meijer
* @since 2.3.0 * @since 2.3.0
*/ */
@ -95,6 +96,8 @@ public class BootBuildImage extends DefaultTask {
private String network; private String network;
private final ListProperty<String> tags;
private final DockerSpec docker = new DockerSpec(); private final DockerSpec docker = new DockerSpec();
public BootBuildImage() { public BootBuildImage() {
@ -106,6 +109,7 @@ public class BootBuildImage extends DefaultTask {
this.projectVersion.set(getProject().provider(() -> project.getVersion().toString())); this.projectVersion.set(getProject().provider(() -> project.getVersion().toString()));
this.buildpacks = getProject().getObjects().listProperty(String.class); this.buildpacks = getProject().getObjects().listProperty(String.class);
this.bindings = getProject().getObjects().listProperty(String.class); this.bindings = getProject().getObjects().listProperty(String.class);
this.tags = getProject().getObjects().listProperty(String.class);
} }
/** /**
@ -379,6 +383,40 @@ public class BootBuildImage extends DefaultTask {
this.bindings.addAll(bindings); this.bindings.addAll(bindings);
} }
/**
* Returns the tags that will be created for the built image.
* @return the tags
*/
@Input
@Optional
public List<String> getTags() {
return this.tags.getOrNull();
}
/**
* Sets the tags that will be created for the built image.
* @param tags the tags
*/
public void setTags(List<String> 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<String> tags) {
this.tags.addAll(tags);
}
/** /**
* Returns the network the build container will connect to. * Returns the network the build container will connect to.
* @return the network * @return the network
@ -460,6 +498,7 @@ public class BootBuildImage extends DefaultTask {
request = customizePublish(request); request = customizePublish(request);
request = customizeBuildpacks(request); request = customizeBuildpacks(request);
request = customizeBindings(request); request = customizeBindings(request);
request = customizeTags(request);
request = request.withNetwork(this.network); request = request.withNetwork(this.network);
return request; return request;
} }
@ -529,6 +568,14 @@ public class BootBuildImage extends DefaultTask {
return request; return request;
} }
private BuildRequest customizeTags(BuildRequest request) {
List<String> 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() { private String translateTargetJavaVersion() {
return this.targetJavaVersion.get().getMajorVersion() + ".*"; return this.targetJavaVersion.get().getMajorVersion() + ".*";
} }

@ -40,7 +40,6 @@ import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.condition.OS;
import org.springframework.boot.buildpack.platform.docker.DockerApi; 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.docker.type.ImageReference;
import org.springframework.boot.buildpack.platform.io.FilePermissions; import org.springframework.boot.buildpack.platform.io.FilePermissions;
import org.springframework.boot.gradle.junit.GradleCompatibility; import org.springframework.boot.gradle.junit.GradleCompatibility;
@ -54,6 +53,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* *
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Scott Frederick * @author Scott Frederick
* @author Rafael Ceccone
*/ */
@GradleCompatibility(configurationCache = true) @GradleCompatibility(configurationCache = true)
@DisabledIfDockerUnavailable @DisabledIfDockerUnavailable
@ -235,6 +235,21 @@ class BootBuildImageIntegrationTests {
removeImage(projectName); 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 @TestTemplate
void buildsImageWithLaunchScript() throws IOException { void buildsImageWithLaunchScript() throws IOException {
writeMainClass(); writeMainClass();
@ -300,6 +315,16 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("'urn:cnb:builder:example/does-not-exist:0.0.1' not found in builder"); assertThat(result.getOutput()).contains("'urn:cnb:builder:example/does-not-exist:0.0.1' not found in builder");
} }
@TestTemplate
void failsWithInvalidTag() 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 { private void writeMainClass() throws IOException {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example"); File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs(); examplePackage.mkdirs();
@ -423,7 +448,7 @@ class BootBuildImageIntegrationTests {
} }
private void removeImage(String name) 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); new DockerApi().image().remove(imageReference, false);
} }

@ -31,6 +31,7 @@ import org.springframework.boot.buildpack.platform.build.BuildRequest;
import org.springframework.boot.buildpack.platform.build.BuildpackReference; import org.springframework.boot.buildpack.platform.build.BuildpackReference;
import org.springframework.boot.buildpack.platform.build.PullPolicy; 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.Binding;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import org.springframework.boot.gradle.junit.GradleProjectBuilder; import org.springframework.boot.gradle.junit.GradleProjectBuilder;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -43,6 +44,7 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
* @author Scott Frederick * @author Scott Frederick
* @author Andrey Shlykov * @author Andrey Shlykov
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Rafael Ceccone
*/ */
class BootBuildImageTests { class BootBuildImageTests {
@ -288,4 +290,34 @@ class BootBuildImageTests {
assertThat(this.buildImage.createRequest().getNetwork()).isEqualTo("test"); assertThat(this.buildImage.createRequest().getNetwork()).isEqualTo("test");
} }
@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"));
}
} }

@ -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" ]
}

@ -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" ]
}

@ -183,6 +183,10 @@ The value supplied will be passed unvalidated to Docker when creating the builde
| Whether to publish the generated image to a Docker registry. | Whether to publish the generated image to a Docker registry.
| `false` | `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. NOTE: The plugin detects the target Java compatibility of the project using the compiler's plugin configuration or the `maven.compiler.target` property.

@ -38,6 +38,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* *
* @author Stephane Nicoll * @author Stephane Nicoll
* @author Scott Frederick * @author Scott Frederick
* @author Rafael Ceccone
*/ */
@ExtendWith(MavenBuildExtension.class) @ExtendWith(MavenBuildExtension.class)
@DisabledIfDockerUnavailable @DisabledIfDockerUnavailable
@ -292,6 +293,19 @@ class BuildImageTests extends AbstractArchiveIntegrationTests {
}); });
} }
@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 @TestTemplate
void failsWhenBuildImageIsInvokedOnMultiModuleProjectWithBuildImageGoal(MavenBuild mavenBuild) { void failsWhenBuildImageIsInvokedOnMultiModuleProjectWithBuildImageGoal(MavenBuild mavenBuild) {
mavenBuild.project("build-image-multi-module").goals("spring-boot:build-image") mavenBuild.project("build-image-multi-module").goals("spring-boot:build-image")

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework.boot.maven.it</groupId>
<artifactId>build-image-tags</artifactId>
<version>0.0.1.BUILD-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>@java.version@</maven.compiler.source>
<maven.compiler.target>@java.version@</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>@project.groupId@</groupId>
<artifactId>@project.artifactId@</artifactId>
<version>@project.version@</version>
<executions>
<execution>
<goals>
<goal>build-image</goal>
</goals>
<configuration>
<image>
<builder>projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.1</builder>
<tags>
<tag>${project.artifactId}:latest</tag>
</tags>
</image>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

@ -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
}
}
}

@ -40,6 +40,7 @@ import org.springframework.util.StringUtils;
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Rafael Ceccone
* @since 2.3.0 * @since 2.3.0
*/ */
public class Image { public class Image {
@ -66,6 +67,8 @@ public class Image {
String network; String network;
List<String> tags;
/** /**
* The name of the created image. * The name of the created image.
* @return the image name * @return the image name
@ -206,6 +209,9 @@ public class Image {
request = request.withBindings(this.bindings.stream().map(Binding::of).collect(Collectors.toList())); request = request.withBindings(this.bindings.stream().map(Binding::of).collect(Collectors.toList()));
} }
request = request.withNetwork(this.network); request = request.withNetwork(this.network);
if (!CollectionUtils.isEmpty(this.tags)) {
request = request.withTags(this.tags.stream().map(ImageReference::of).collect(Collectors.toList()));
}
return request; return request;
} }

@ -30,6 +30,7 @@ import org.springframework.boot.buildpack.platform.build.BuildRequest;
import org.springframework.boot.buildpack.platform.build.BuildpackReference; import org.springframework.boot.buildpack.platform.build.BuildpackReference;
import org.springframework.boot.buildpack.platform.build.PullPolicy; 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.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.Owner;
import org.springframework.boot.buildpack.platform.io.TarArchive; import org.springframework.boot.buildpack.platform.io.TarArchive;
@ -42,6 +43,7 @@ import static org.assertj.core.api.Assertions.entry;
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Rafael Ceccone
*/ */
class ImageTests { class ImageTests {
@ -156,6 +158,15 @@ class ImageTests {
assertThat(request.getNetwork()).isEqualTo("test"); assertThat(request.getNetwork()).isEqualTo("test");
} }
@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() { private Artifact createArtifact() {
return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile", return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile",
"jar", null, new DefaultArtifactHandler()); "jar", null, new DefaultArtifactHandler());

Loading…
Cancel
Save