Merge pull request #28292 from jeliebig

* pr/28292:
  Polish "Add option to customize cache volume names when building an image"
  Add option to customize cache volume names when building an image

Closes gh-28292
pull/28398/head
Scott Frederick 3 years ago
commit 384a07cd69

@ -38,6 +38,7 @@ import org.springframework.util.Assert;
* @author Andrey Shlykov * @author Andrey Shlykov
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Rafael Ceccone * @author Rafael Ceccone
* @author Julian Liebig
* @since 2.3.0 * @since 2.3.0
*/ */
public class BuildRequest { public class BuildRequest {
@ -74,6 +75,10 @@ public class BuildRequest {
private final List<ImageReference> tags; private final List<ImageReference> tags;
private final Cache buildCache;
private final Cache launchCache;
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");
@ -91,12 +96,14 @@ public class BuildRequest {
this.bindings = Collections.emptyList(); this.bindings = Collections.emptyList();
this.network = null; this.network = null;
this.tags = Collections.emptyList(); this.tags = Collections.emptyList();
this.buildCache = null;
this.launchCache = null;
} }
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<ImageReference> tags) { List<Binding> bindings, String network, List<ImageReference> tags, Cache buildCache, Cache launchCache) {
this.name = name; this.name = name;
this.applicationContent = applicationContent; this.applicationContent = applicationContent;
this.builder = builder; this.builder = builder;
@ -111,6 +118,8 @@ public class BuildRequest {
this.bindings = bindings; this.bindings = bindings;
this.network = network; this.network = network;
this.tags = tags; this.tags = tags;
this.buildCache = buildCache;
this.launchCache = launchCache;
} }
/** /**
@ -122,7 +131,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.tags); this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -133,7 +142,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.tags); this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -145,7 +154,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.tags); this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -161,7 +170,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.tags); this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -175,7 +184,8 @@ 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.tags); this.publish, this.buildpacks, this.bindings, this.network, this.tags, this.buildCache,
this.launchCache);
} }
/** /**
@ -186,7 +196,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.tags); this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -197,7 +207,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.tags); this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -208,7 +218,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.tags); this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -219,7 +229,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.tags); this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -243,7 +253,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.tags); this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -267,7 +277,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.tags); this.network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -279,7 +289,7 @@ 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, this.tags); network, this.tags, this.buildCache, this.launchCache);
} }
/** /**
@ -301,7 +311,31 @@ public class BuildRequest {
Assert.notNull(tags, "Tags must not be null"); Assert.notNull(tags, "Tags 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, this.bindings, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
this.network, tags); this.network, tags, this.buildCache, this.launchCache);
}
/**
* Return a new {@link BuildRequest} with an updated build cache.
* @param buildCache the build cache
* @return an updated build request
*/
public BuildRequest withBuildCache(Cache buildCache) {
Assert.notNull(buildCache, "BuildCache 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, this.tags, buildCache, this.launchCache);
}
/**
* Return a new {@link BuildRequest} with an updated launch cache.
* @param launchCache the cache
* @return an updated build request
*/
public BuildRequest withLaunchCache(Cache launchCache) {
Assert.notNull(launchCache, "LaunchCache 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, this.tags, this.buildCache, launchCache);
} }
/** /**
@ -421,6 +455,22 @@ public class BuildRequest {
return this.tags; return this.tags;
} }
/**
* Return the custom build cache that should be used by the lifecycle.
* @return the build cache
*/
public Cache getBuildCache() {
return this.buildCache;
}
/**
* Return the custom launch cache that should be used by the lifecycle.
* @return the launch cache
*/
public Cache getLaunchCache() {
return this.launchCache;
}
/** /**
* 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

@ -0,0 +1,125 @@
/*
* 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.springframework.boot.buildpack.platform.build;
import java.util.Objects;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* Details of a cache for use by the CNB builder.
*
* @author Scott Frederick
* @since 2.6.0
*/
public class Cache {
/**
* The format of the cache.
*/
public enum Format {
/**
* A cache stored as a volume in the Docker daemon.
*/
VOLUME;
}
protected final Format format;
Cache(Format format) {
this.format = format;
}
/**
* Return the details of the cache if it is a volume cache.
* @return the cache, or {@code null} if it is not a volume cache
*/
public Volume getVolume() {
return (this.format.equals(Format.VOLUME)) ? (Volume) this : null;
}
/**
* Create a new {@code Cache} that uses a volume with the provided name.
* @param name the cache volume name
* @return a new cache instance
*/
public static Cache volume(String name) {
Assert.notNull(name, "Name must not be null");
return new Volume(name);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
Cache other = (Cache) obj;
return Objects.equals(this.format, other.format);
}
@Override
public int hashCode() {
return ObjectUtils.nullSafeHashCode(this.format);
}
/**
* Details of a cache stored in a Docker volume.
*/
public static class Volume extends Cache {
private final String name;
Volume(String name) {
super(Format.VOLUME);
this.name = name;
}
public String getName() {
return this.name;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
if (!super.equals(obj)) {
return false;
}
Volume other = (Volume) obj;
return Objects.equals(this.name, other.name);
}
@Override
public int hashCode() {
int result = super.hashCode();
result = 31 * result + ObjectUtils.nullSafeHashCode(this.name);
return result;
}
}
}

@ -39,6 +39,7 @@ import org.springframework.util.Assert;
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick * @author Scott Frederick
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Julian Liebig
*/ */
class Lifecycle implements Closeable { class Lifecycle implements Closeable {
@ -86,16 +87,37 @@ class Lifecycle implements Closeable {
this.platformVersion = getPlatformVersion(builder.getBuilderMetadata().getLifecycle()); this.platformVersion = getPlatformVersion(builder.getBuilderMetadata().getLifecycle());
this.layersVolume = createRandomVolumeName("pack-layers-"); this.layersVolume = createRandomVolumeName("pack-layers-");
this.applicationVolume = createRandomVolumeName("pack-app-"); this.applicationVolume = createRandomVolumeName("pack-app-");
this.buildCacheVolume = createCacheVolumeName(request, ".build"); this.buildCacheVolume = getBuildCacheVolumeName(request);
this.launchCacheVolume = createCacheVolumeName(request, ".launch"); this.launchCacheVolume = getLaunchCacheVolumeName(request);
} }
protected VolumeName createRandomVolumeName(String prefix) { protected VolumeName createRandomVolumeName(String prefix) {
return VolumeName.random(prefix); return VolumeName.random(prefix);
} }
private VolumeName getBuildCacheVolumeName(BuildRequest request) {
if (request.getBuildCache() != null) {
return getVolumeName(request.getBuildCache());
}
return createCacheVolumeName(request, "build");
}
private VolumeName getLaunchCacheVolumeName(BuildRequest request) {
if (request.getLaunchCache() != null) {
return getVolumeName(request.getLaunchCache());
}
return createCacheVolumeName(request, "launch");
}
private VolumeName getVolumeName(Cache cache) {
if (cache.getVolume() != null) {
return VolumeName.of(cache.getVolume().getName());
}
return null;
}
private VolumeName createCacheVolumeName(BuildRequest request, String suffix) { private VolumeName createCacheVolumeName(BuildRequest request, String suffix) {
return VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", suffix, 6); return VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", "." + suffix, 6);
} }
private ApiVersion getPlatformVersion(BuilderMetadata.Lifecycle lifecycle) { private ApiVersion getPlatformVersion(BuilderMetadata.Lifecycle lifecycle) {

@ -210,11 +210,11 @@ class BuildRequestTests {
@Test @Test
void withTagsAddsTags() throws IOException { void withTagsAddsTags() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
BuildRequest witTags = request.withTags(ImageReference.of("docker.io/library/my-app:latest"), BuildRequest withTags = 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:0.0.1"),
ImageReference.of("example.com/custom/my-app:latest")); ImageReference.of("example.com/custom/my-app:latest"));
assertThat(request.getTags()).isEmpty(); assertThat(request.getTags()).isEmpty();
assertThat(witTags.getTags()).containsExactly(ImageReference.of("docker.io/library/my-app:latest"), assertThat(withTags.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:0.0.1"),
ImageReference.of("example.com/custom/my-app:latest")); ImageReference.of("example.com/custom/my-app:latest"));
} }
@ -226,6 +226,36 @@ class BuildRequestTests {
.withMessage("Tags must not be null"); .withMessage("Tags must not be null");
} }
@Test
void withBuildVolumeCacheAddsCache() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
BuildRequest withCache = request.withBuildCache(Cache.volume("build-volume"));
assertThat(request.getBuildCache()).isNull();
assertThat(withCache.getBuildCache()).isEqualTo(Cache.volume("build-volume"));
}
@Test
void withBuildVolumeCacheWhenCacheIsNullThrowsException() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
assertThatIllegalArgumentException().isThrownBy(() -> request.withBuildCache(null))
.withMessage("BuildCache must not be null");
}
@Test
void withLaunchVolumeCacheAddsCache() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
BuildRequest withCache = request.withLaunchCache(Cache.volume("launch-volume"));
assertThat(request.getLaunchCache()).isNull();
assertThat(withCache.getLaunchCache()).isEqualTo(Cache.volume("launch-volume"));
}
@Test
void withLaunchVolumeCacheWhenCacheIsNullThrowsException() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
assertThatIllegalArgumentException().isThrownBy(() -> request.withLaunchCache(null))
.withMessage("LaunchCache must not be null");
}
private void hasExpectedJarContent(TarArchive archive) { private void hasExpectedJarContent(TarArchive archive) {
try { try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

@ -200,6 +200,18 @@ class LifecycleTests {
assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
} }
@Test
void executeWithCacheVolumeNamesExecutesPhases() throws Exception {
given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null));
BuildRequest request = getTestRequest().withBuildCache(Cache.volume("build-volume"))
.withLaunchCache(Cache.volume("launch-volume"));
createLifecycle(request).execute();
assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-cache-volumes.json"));
assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
}
private DockerApi mockDockerApi() { private DockerApi mockDockerApi() {
DockerApi docker = mock(DockerApi.class); DockerApi docker = mock(DockerApi.class);
ImageApi imageApi = mock(ImageApi.class); ImageApi imageApi = mock(ImageApi.class);

@ -0,0 +1,37 @@
{
"User": "root",
"Image": "pack.local/ephemeral-builder",
"Cmd": [
"/cnb/lifecycle/creator",
"-app",
"/workspace",
"-platform",
"/platform",
"-run-image",
"docker.io/cloudfoundry/run:latest",
"-layers",
"/layers",
"-cache-dir",
"/cache",
"-launch-cache",
"/launch-cache",
"-daemon",
"-process-type=web",
"docker.io/library/my-application:latest"
],
"Env": [
"CNB_PLATFORM_API=0.4"
],
"Labels": {
"author": "spring-boot"
},
"HostConfig": {
"Binds": [
"/var/run/docker.sock:/var/run/docker.sock",
"pack-layers-aaaaaaaaaa:/layers",
"pack-app-aaaaaaaaaa:/workspace",
"build-volume:/cache",
"launch-volume:/launch-cache"
]
}
}

@ -181,6 +181,16 @@ The value supplied will be passed unvalidated to Docker when creating the builde
| A list of one or more additional tags to apply to the generated image. | A list of one or more additional tags to apply to the generated image.
| |
| `buildCache`
|
| A cache containing layers created by buildpacks and used by the image building process.
| A named volume in the Docker daemon, with a name derived from the image name.
| `launchCache`
|
| A cache containing layers created by buildpacks and used by the image launching process.
| A named volume in the Docker daemon, with a name derived from the image name.
|=== |===
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.
@ -379,10 +389,30 @@ The publish option can be specified on the command line as well, as shown in thi
$ gradle bootBuildImage --imageName=docker.example.com/library/my-app:v1 --publishImage $ gradle bootBuildImage --imageName=docker.example.com/library/my-app:v1 --publishImage
---- ----
[[build-image.examples.caches]]
=== Builder Cache Configuration
The CNB builder caches layers that are used when building and launching an image.
By default, these caches are stored as named volumes in the Docker daemon with names that are derived from the full name of the target image.
If the image name changes frequently, for example when the project version is used as a tag in the image name, then the caches can be invalidated frequently.
The cache volumes can be configured to use alternative names to give more control over cache lifecycle as shown in the following example:
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
.Groovy
----
include::../gradle/packaging/boot-build-image-caches.gradle[tags=caches]
----
[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
.Kotlin
----
include::../gradle/packaging/boot-build-image-caches.gradle.kts[tags=caches]
----
[[build-image.examples.docker]] [[build-image.examples.docker]]
=== Docker Configuration === Docker Configuration
If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` properties as shown in the following example: If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` properties as shown in the following example:
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"] [source,groovy,indent=0,subs="verbatim,attributes",role="primary"]

@ -0,0 +1,30 @@
plugins {
id 'java'
id 'org.springframework.boot' version '{gradle-project-version}'
}
bootJar {
mainClass = 'com.example.ExampleApplication'
}
// tag::caches[]
bootBuildImage {
buildCache {
volume {
name = "cache-${rootProject.name}.build"
}
}
launchCache {
volume {
name = "cache-${rootProject.name}.launch"
}
}
}
// end::caches[]
task bootBuildImageCaches {
doFirst {
bootBuildImage.buildCache.asCache().with { println "buildCache=$name" }
bootBuildImage.launchCache.asCache().with { println "launchCache=$name" }
}
}

@ -0,0 +1,28 @@
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
plugins {
java
id("org.springframework.boot") version "{gradle-project-version}"
}
// tag::caches[]
tasks.getByName<BootBuildImage>("bootBuildImage") {
buildCache {
volume {
name = "cache-${rootProject.name}.build"
}
}
launchCache {
volume {
name = "cache-${rootProject.name}.launch"
}
}
}
// end::caches[]
tasks.register("bootBuildImageCaches") {
doFirst {
println("buildCache=" + tasks.getByName<BootBuildImage>("bootBuildImage").buildCache.asCache().volume.name)
println("launchCache=" + tasks.getByName<BootBuildImage>("bootBuildImage").launchCache.asCache().volume.name)
}
}

@ -60,6 +60,7 @@ import org.springframework.util.StringUtils;
* @author Scott Frederick * @author Scott Frederick
* @author Rafael Ceccone * @author Rafael Ceccone
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Julian Liebig
* @since 2.3.0 * @since 2.3.0
*/ */
public class BootBuildImage extends DefaultTask { public class BootBuildImage extends DefaultTask {
@ -98,6 +99,10 @@ public class BootBuildImage extends DefaultTask {
private final ListProperty<String> tags; private final ListProperty<String> tags;
private final CacheSpec buildCache = new CacheSpec();
private final CacheSpec launchCache = new CacheSpec();
private final DockerSpec docker = new DockerSpec(); private final DockerSpec docker = new DockerSpec();
public BootBuildImage() { public BootBuildImage() {
@ -436,6 +441,62 @@ public class BootBuildImage extends DefaultTask {
this.network = network; this.network = network;
} }
/**
* Returns the build cache that will be used when building the image.
* @return the cache
*/
@Nested
@Optional
public CacheSpec getBuildCache() {
return this.buildCache;
}
/**
* Customizes the {@link CacheSpec} for the build cache using the given
* {@code action}.
* @param action the action
*/
public void buildCache(Action<CacheSpec> action) {
action.execute(this.buildCache);
}
/**
* Customizes the {@link CacheSpec} for the build cache using the given
* {@code closure}.
* @param closure the closure
*/
public void buildCache(Closure<?> closure) {
buildCache(ConfigureUtil.configureUsing(closure));
}
/**
* Returns the launch cache that will be used when building the image.
* @return the cache
*/
@Nested
@Optional
public CacheSpec getLaunchCache() {
return this.launchCache;
}
/**
* Customizes the {@link CacheSpec} for the launch cache using the given
* {@code action}.
* @param action the action
*/
public void launchCache(Action<CacheSpec> action) {
action.execute(this.launchCache);
}
/**
* Customizes the {@link CacheSpec} for the launch cache using the given
* {@code closure}.
* @param closure the closure
*/
public void launchCache(Closure<?> closure) {
launchCache(ConfigureUtil.configureUsing(closure));
}
/** /**
* Returns the Docker configuration the builder will use. * Returns the Docker configuration the builder will use.
* @return docker configuration. * @return docker configuration.
@ -499,6 +560,7 @@ public class BootBuildImage extends DefaultTask {
request = customizeBuildpacks(request); request = customizeBuildpacks(request);
request = customizeBindings(request); request = customizeBindings(request);
request = customizeTags(request); request = customizeTags(request);
request = customizeCaches(request);
request = request.withNetwork(this.network); request = request.withNetwork(this.network);
return request; return request;
} }
@ -576,6 +638,16 @@ public class BootBuildImage extends DefaultTask {
return request; return request;
} }
private BuildRequest customizeCaches(BuildRequest request) {
if (this.buildCache.asCache() != null) {
request = request.withBuildCache(this.buildCache.asCache());
}
if (this.launchCache.asCache() != null) {
request = request.withLaunchCache(this.launchCache.asCache());
}
return request;
}
private String translateTargetJavaVersion() { private String translateTargetJavaVersion() {
return this.targetJavaVersion.get().getMajorVersion() + ".*"; return this.targetJavaVersion.get().getMajorVersion() + ".*";
} }

@ -0,0 +1,94 @@
/*
* Copyright 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.springframework.boot.gradle.tasks.bundling;
import groovy.lang.Closure;
import org.gradle.api.Action;
import org.gradle.api.GradleException;
import org.gradle.api.tasks.Input;
import org.gradle.util.ConfigureUtil;
import org.springframework.boot.buildpack.platform.build.Cache;
/**
* Configuration for an image building cache.
*
* @author Scott Frederick
* @since 2.6.0
*/
public class CacheSpec {
private Cache cache = null;
CacheSpec() {
}
public Cache asCache() {
return this.cache;
}
/**
* Configures a volume cache using the given {@code action}.
* @param action the action
*/
public void volume(Action<VolumeCacheSpec> action) {
if (this.cache != null) {
throw new GradleException("Each image building cache can be configured only once");
}
VolumeCacheSpec spec = new VolumeCacheSpec();
action.execute(spec);
this.cache = Cache.volume(spec.getName());
}
/**
* Configures a volume cache using the given {@code closure}.
* @param closure the closure
*/
public void volume(Closure<?> closure) {
if (this.cache != null) {
throw new GradleException("Each image building cache can be configured only once");
}
volume(ConfigureUtil.configureUsing(closure));
}
/**
* Configuration for an image building cache stored in a Docker volume.
*/
public static class VolumeCacheSpec {
private String name;
/**
* Returns the name of the cache.
* @return the cache name
*/
@Input
public String getName() {
return this.name;
}
/**
* Sets the name of the cache.
* @param name the cache name
*/
public void setName(String name) {
this.name = name;
}
}
}

@ -316,6 +316,14 @@ class PackagingDocumentationTests {
.contains("urn:cnb:builder:paketo-buildpacks/java"); .contains("urn:cnb:builder:paketo-buildpacks/java");
} }
@TestTemplate
void bootBuildImageWithCaches() {
BuildResult result = this.gradleBuild.script("src/docs/gradle/packaging/boot-build-image-caches")
.build("bootBuildImageCaches");
assertThat(result.getOutput()).containsPattern("buildCache=cache-gradle-[\\d]+.build")
.containsPattern("launchCache=cache-gradle-[\\d]+.launch");
}
protected void jarFile(File file) throws IOException { protected void jarFile(File file) throws IOException {
try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) { try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) {
jar.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); jar.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));

@ -40,7 +40,10 @@ 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.DockerApi.ImageApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
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;
import org.springframework.boot.testsupport.gradle.testkit.GradleBuild; import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
@ -73,7 +76,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("env: BP_JVM_VERSION=8.*"); assertThat(result.getOutput()).contains("env: BP_JVM_VERSION=8.*");
assertThat(result.getOutput()).contains("Network status: HTTP/2 200"); assertThat(result.getOutput()).contains("Network status: HTTP/2 200");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -91,7 +94,7 @@ class BootBuildImageIntegrationTests {
File buildLibs = new File(this.gradleBuild.getProjectDir(), "build/libs"); File buildLibs = new File(this.gradleBuild.getProjectDir(), "build/libs");
assertThat(buildLibs.listFiles()) assertThat(buildLibs.listFiles())
.containsExactly(new File(buildLibs, this.gradleBuild.getProjectDir().getName() + ".war")); .containsExactly(new File(buildLibs, this.gradleBuild.getProjectDir().getName() + ".war"));
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -107,7 +110,7 @@ class BootBuildImageIntegrationTests {
File buildLibs = new File(this.gradleBuild.getProjectDir(), "build/libs"); File buildLibs = new File(this.gradleBuild.getProjectDir(), "build/libs");
assertThat(buildLibs.listFiles()) assertThat(buildLibs.listFiles())
.containsExactly(new File(buildLibs, this.gradleBuild.getProjectDir().getName() + ".war")); .containsExactly(new File(buildLibs, this.gradleBuild.getProjectDir().getName() + ".war"));
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -119,7 +122,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("example/test-image-name"); assertThat(result.getOutput()).contains("example/test-image-name");
assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack building");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
removeImage("example/test-image-name"); removeImages("example/test-image-name");
} }
@TestTemplate @TestTemplate
@ -131,7 +134,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("example/test-image-custom"); assertThat(result.getOutput()).contains("example/test-image-custom");
assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack building");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
removeImage("example/test-image-custom"); removeImages("example/test-image-custom");
} }
@TestTemplate @TestTemplate
@ -146,7 +149,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("example/test-image-cmd"); assertThat(result.getOutput()).contains("example/test-image-cmd");
assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack building");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
removeImage("example/test-image-cmd"); removeImages("example/test-image-cmd");
} }
@TestTemplate @TestTemplate
@ -160,7 +163,7 @@ class BootBuildImageIntegrationTests {
result = this.gradleBuild.build("bootBuildImage", "--pullPolicy=IF_NOT_PRESENT"); result = this.gradleBuild.build("bootBuildImage", "--pullPolicy=IF_NOT_PRESENT");
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).doesNotContain("Pulled builder image").doesNotContain("Pulled run image"); assertThat(result.getOutput()).doesNotContain("Pulled builder image").doesNotContain("Pulled run image");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -173,7 +176,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("docker.io/library/" + projectName);
assertThat(result.getOutput()).contains("---> Test Info buildpack building") assertThat(result.getOutput()).contains("---> Test Info buildpack building")
.contains("---> Test Info buildpack done"); .contains("---> Test Info buildpack done");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -187,7 +190,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("docker.io/library/" + projectName);
assertThat(result.getOutput()).contains("---> Hello World buildpack"); assertThat(result.getOutput()).contains("---> Hello World buildpack");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -202,7 +205,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("docker.io/library/" + projectName);
assertThat(result.getOutput()).contains("---> Hello World buildpack"); assertThat(result.getOutput()).contains("---> Hello World buildpack");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -215,7 +218,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("docker.io/library/" + projectName);
assertThat(result.getOutput()).contains("---> Test Info buildpack building") assertThat(result.getOutput()).contains("---> Test Info buildpack building")
.contains("---> Test Info buildpack done"); .contains("---> Test Info buildpack done");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -232,7 +235,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("binding: certificates/test1.crt=---certificate one---"); assertThat(result.getOutput()).contains("binding: certificates/test1.crt=---certificate one---");
assertThat(result.getOutput()).contains("binding: certificates/test2.crt=---certificate two---"); assertThat(result.getOutput()).contains("binding: certificates/test2.crt=---certificate two---");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -246,8 +249,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack building");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
assertThat(result.getOutput()).contains("example.com/myapp:latest"); assertThat(result.getOutput()).contains("example.com/myapp:latest");
removeImage(projectName); removeImages(projectName, "example.com/myapp:latest");
removeImage("example.com/myapp:latest");
} }
@TestTemplate @TestTemplate
@ -260,7 +262,7 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("docker.io/library/" + projectName);
assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack building");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
removeImage(projectName); removeImages(projectName);
} }
@TestTemplate @TestTemplate
@ -274,7 +276,21 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack building");
assertThat(result.getOutput()).contains("Network status: curl failed"); assertThat(result.getOutput()).contains("Network status: curl failed");
assertThat(result.getOutput()).contains("---> Test Info buildpack done"); assertThat(result.getOutput()).contains("---> Test Info buildpack done");
removeImage(projectName); removeImages(projectName);
}
@TestTemplate
void buildsImageWithVolumeCaches() 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");
removeImages(projectName);
deleteVolumes("build-cache-volume", "launch-cache-volume");
} }
@TestTemplate @TestTemplate
@ -325,6 +341,14 @@ class BootBuildImageIntegrationTests {
.containsPattern("example/Invalid-Tag-Name"); .containsPattern("example/Invalid-Tag-Name");
} }
@TestTemplate
void failsWhenCachesAreConfiguredTwice() throws IOException {
writeMainClass();
writeLongNameResource();
BuildResult result = this.gradleBuild.buildAndFail("bootBuildImage");
assertThat(result.getOutput()).containsPattern("Each image building cache can be configured only once");
}
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();
@ -447,9 +471,18 @@ class BootBuildImageIntegrationTests {
} }
} }
private void removeImage(String name) throws IOException { private void removeImages(String... names) throws IOException {
ImageReference imageReference = ImageReference.of(name); ImageApi imageApi = new DockerApi().image();
new DockerApi().image().remove(imageReference, false); for (String name : names) {
imageApi.remove(ImageReference.of(name), false);
}
}
private void deleteVolumes(String... names) throws IOException {
VolumeApi volumeApi = new DockerApi().volume();
for (String name : names) {
volumeApi.delete(VolumeName.of(name), false);
}
} }
} }

@ -0,0 +1,21 @@
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"
buildCache {
volume {
name = "build-cache-volume"
}
}
launchCache {
volume {
name = "launch-cache-volume"
}
}
}

@ -0,0 +1,19 @@
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"
buildCache {
volume {
name = "build-cache-volume1"
}
volume {
name = "build-cache-volum2"
}
}
}

@ -187,6 +187,18 @@ The value supplied will be passed unvalidated to Docker when creating the builde
| One or more additional tags to apply to the generated image. | One or more additional tags to apply to the generated image.
| |
| `caches`
| Cache volume names that should be used by the builder instead of generating random names.
|
| `buildCache`
| A cache containing layers created by buildpacks and used by the image building process.
| A named volume in the Docker daemon, with a name derived from the image name.
| `launchCache`
| A cache containing layers created by buildpacks and used by the image launching process.
| A named volume in the Docker daemon, with a name derived from the image name.
|=== |===
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.
@ -340,10 +352,23 @@ The `publish` option can be specified on the command line as well, as shown in t
$ mvn spring-boot:build-image -Dspring-boot.build-image.imageName=docker.example.com/library/my-app:v1 -Dspring-boot.build-image.publish=true $ 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.examples.caches]]
=== Builder Cache Configuration
The CNB builder caches layers that are used when building and launching an image.
By default, these caches are stored as named volumes in the Docker daemon with names that are derived from the full name of the target image.
If the image name changes frequently, for example when the project version is used as a tag in the image name, then the caches can be invalidated frequently.
The cache volumes can be configured to use alternative names to give more control over cache lifecycle as shown in the following example:
[source,xml,indent=0,subs="verbatim,attributes",tabsize=4]
----
include::../maven/packaging-oci-image/caches-pom.xml[tags=caches]
----
[[build-image.examples.docker]] [[build-image.examples.docker]]
=== Docker Configuration === Docker Configuration
If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` parameters as shown in the following example: If you need the plugin to communicate with the Docker daemon using a remote connection instead of the default local connection, the connection details can be provided using `docker` parameters as shown in the following example:
[source,xml,indent=0,subs="verbatim,attributes",tabsize=4] [source,xml,indent=0,subs="verbatim,attributes",tabsize=4]

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- tag::caches[] -->
<project>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<buildCache>
<volume>
<name>cache-${project.artifactId}.build</name>
</volume>
</buildCache>
<launchCache>
<volume>
<name>cache-${project.artifactId}.launch</name>
</volume>
</launchCache>
</image>
</configuration>
</plugin>
</plugins>
</build>
</project>
<!-- end::caches[] -->

@ -27,8 +27,10 @@ import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.buildpack.platform.docker.DockerApi; import org.springframework.boot.buildpack.platform.docker.DockerApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
import org.springframework.boot.buildpack.platform.docker.type.ImageName; 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.docker.type.VolumeName;
import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -306,6 +308,18 @@ class BuildImageTests extends AbstractArchiveIntegrationTests {
}); });
} }
@TestTemplate
void whenBuildImageIsInvokedWithVolumeCaches(MavenBuild mavenBuild) {
mavenBuild.project("build-image-caches").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-caches:0.0.1.BUILD-SNAPSHOT")
.contains("Successfully built image");
removeImage("build-image-caches", "0.0.1.BUILD-SNAPSHOT");
deleteVolumes("build-cache-volume", "launch-cache-volume");
});
}
@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")
@ -343,6 +357,13 @@ class BuildImageTests extends AbstractArchiveIntegrationTests {
.contains("is required for building an image")); .contains("is required for building an image"));
} }
@TestTemplate
void failsWhenCachesAreConfiguredTwice(MavenBuild mavenBuild) {
mavenBuild.project("build-image-caches-multiple").goals("package")
.executeAndFail((project) -> assertThat(buildLog(project))
.contains("Each image building cache can be configured only once"));
}
private void writeLongNameResource(File project) { private void writeLongNameResource(File project) {
StringBuilder name = new StringBuilder(); StringBuilder name = new StringBuilder();
new Random().ints('a', 'z' + 1).limit(128).forEach((i) -> name.append((char) i)); new Random().ints('a', 'z' + 1).limit(128).forEach((i) -> name.append((char) i));
@ -366,4 +387,11 @@ class BuildImageTests extends AbstractArchiveIntegrationTests {
} }
} }
private void deleteVolumes(String... names) throws IOException {
VolumeApi volumeApi = new DockerApi().volume();
for (String name : names) {
volumeApi.delete(VolumeName.of(name), false);
}
}
} }

@ -0,0 +1,42 @@
<?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-caches-multiple</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>
<buildCache>
<volume>
<name>build-cache-volume1</name>
</volume>
<volume>
<name>build-cache-volume2</name>
</volume>
</buildCache>
</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
}
}
}

@ -0,0 +1,44 @@
<?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-caches</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>
<buildCache>
<volume>
<name>build-cache-volume</name>
</volume>
</buildCache>
<launchCache>
<volume>
<name>launch-cache-volume</name>
</volume>
</launchCache>
</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
}
}
}

@ -0,0 +1,72 @@
/*
* 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.springframework.boot.maven;
import org.springframework.boot.buildpack.platform.build.Cache;
import org.springframework.util.Assert;
/**
* Encapsulates configuration of an image building cache.
*
* @author Scott Frederick
* @since 2.6.0
*/
public class CacheInfo {
private Cache cache;
public CacheInfo() {
}
CacheInfo(VolumeCacheInfo volumeCacheInfo) {
this.cache = Cache.volume(volumeCacheInfo.getName());
}
public void setVolume(VolumeCacheInfo info) {
Assert.state(this.cache == null, "Each image building cache can be configured only once");
this.cache = Cache.volume(info.getName());
}
Cache asCache() {
return this.cache;
}
/**
* Encapsulates configuration of an image building cache stored in a volume.
*/
public static class VolumeCacheInfo {
private String name;
public VolumeCacheInfo() {
}
VolumeCacheInfo(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
void setName(String name) {
this.name = name;
}
}
}

@ -41,6 +41,7 @@ import org.springframework.util.StringUtils;
* @author Scott Frederick * @author Scott Frederick
* @author Jeroen Meijer * @author Jeroen Meijer
* @author Rafael Ceccone * @author Rafael Ceccone
* @author Julian Liebig
* @since 2.3.0 * @since 2.3.0
*/ */
public class Image { public class Image {
@ -69,6 +70,10 @@ public class Image {
List<String> tags; List<String> tags;
CacheInfo buildCache;
CacheInfo launchCache;
/** /**
* The name of the created image. * The name of the created image.
* @return the image name * @return the image name
@ -212,6 +217,12 @@ public class Image {
if (!CollectionUtils.isEmpty(this.tags)) { if (!CollectionUtils.isEmpty(this.tags)) {
request = request.withTags(this.tags.stream().map(ImageReference::of).collect(Collectors.toList())); request = request.withTags(this.tags.stream().map(ImageReference::of).collect(Collectors.toList()));
} }
if (this.buildCache != null) {
request = request.withBuildCache(this.buildCache.asCache());
}
if (this.launchCache != null) {
request = request.withLaunchCache(this.launchCache.asCache());
}
return request; return request;
} }

@ -28,11 +28,13 @@ import org.junit.jupiter.api.Test;
import org.springframework.boot.buildpack.platform.build.BuildRequest; 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.Cache;
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.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;
import org.springframework.boot.maven.CacheInfo.VolumeCacheInfo;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry; import static org.assertj.core.api.Assertions.entry;
@ -167,6 +169,22 @@ class ImageTests {
ImageReference.of("example.com/my-app:0.0.1-SNAPSHOT"), ImageReference.of("example.com/my-app:latest")); ImageReference.of("example.com/my-app:0.0.1-SNAPSHOT"), ImageReference.of("example.com/my-app:latest"));
} }
@Test
void getBuildRequestWhenHasBuildVolumeCacheUsesCache() {
Image image = new Image();
image.buildCache = new CacheInfo(new VolumeCacheInfo("build-cache-vol"));
BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
assertThat(request.getBuildCache()).isEqualTo(Cache.volume("build-cache-vol"));
}
@Test
void getBuildRequestWhenHasLaunchVolumeCacheUsesCache() {
Image image = new Image();
image.launchCache = new CacheInfo(new VolumeCacheInfo("launch-cache-vol"));
BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
assertThat(request.getLaunchCache()).isEqualTo(Cache.volume("launch-cache-vol"));
}
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