diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java index 0d637a79eb..577600f920 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java @@ -38,6 +38,7 @@ import org.springframework.util.Assert; * @author Andrey Shlykov * @author Jeroen Meijer * @author Rafael Ceccone + * @author Julian Liebig * @since 2.3.0 */ public class BuildRequest { @@ -74,6 +75,10 @@ public class BuildRequest { private final List tags; + private final Cache buildCache; + + private final Cache launchCache; + BuildRequest(ImageReference name, Function applicationContent) { Assert.notNull(name, "Name must not be null"); Assert.notNull(applicationContent, "ApplicationContent must not be null"); @@ -91,12 +96,14 @@ public class BuildRequest { this.bindings = Collections.emptyList(); this.network = null; this.tags = Collections.emptyList(); + this.buildCache = null; + this.launchCache = null; } BuildRequest(ImageReference name, Function applicationContent, ImageReference builder, ImageReference runImage, Creator creator, Map env, boolean cleanCache, boolean verboseLogging, PullPolicy pullPolicy, boolean publish, List buildpacks, - List bindings, String network, List tags) { + List bindings, String network, List tags, Cache buildCache, Cache launchCache) { this.name = name; this.applicationContent = applicationContent; this.builder = builder; @@ -111,6 +118,8 @@ public class BuildRequest { this.bindings = bindings; this.network = network; this.tags = tags; + this.buildCache = buildCache; + this.launchCache = launchCache; } /** @@ -122,7 +131,7 @@ public class BuildRequest { Assert.notNull(builder, "Builder must not be null"); return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, - this.buildpacks, this.bindings, this.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) { return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(), this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, - this.buildpacks, this.bindings, this.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"); 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.network, this.tags); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -161,7 +170,7 @@ public class BuildRequest { env.put(name, value); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, - this.buildpacks, this.bindings, this.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); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy, - this.publish, this.buildpacks, this.bindings, this.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) { 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, - this.network, this.tags); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -197,7 +207,7 @@ public class BuildRequest { public BuildRequest withVerboseLogging(boolean verboseLogging) { return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, verboseLogging, this.pullPolicy, this.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) { 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.network, this.tags); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -219,7 +229,7 @@ public class BuildRequest { public BuildRequest withPublish(boolean publish) { return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, publish, this.buildpacks, this.bindings, - this.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"); 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.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"); 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.network, this.tags); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -279,7 +289,7 @@ public class BuildRequest { public BuildRequest withNetwork(String network) { 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, - 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"); 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); + 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 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. * @param jarFile the source jar file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java new file mode 100644 index 0000000000..9f3087f94c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java @@ -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; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java index ae372bc71e..c7162c6d21 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java @@ -39,6 +39,7 @@ import org.springframework.util.Assert; * @author Phillip Webb * @author Scott Frederick * @author Jeroen Meijer + * @author Julian Liebig */ class Lifecycle implements Closeable { @@ -86,16 +87,37 @@ class Lifecycle implements Closeable { this.platformVersion = getPlatformVersion(builder.getBuilderMetadata().getLifecycle()); this.layersVolume = createRandomVolumeName("pack-layers-"); this.applicationVolume = createRandomVolumeName("pack-app-"); - this.buildCacheVolume = createCacheVolumeName(request, ".build"); - this.launchCacheVolume = createCacheVolumeName(request, ".launch"); + this.buildCacheVolume = getBuildCacheVolumeName(request); + this.launchCacheVolume = getLaunchCacheVolumeName(request); } protected VolumeName createRandomVolumeName(String 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) { - 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) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java index d3008b96a5..0667e77084 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java @@ -210,11 +210,11 @@ class BuildRequestTests { @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"), + 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:latest")); 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:latest")); } @@ -226,6 +226,36 @@ class BuildRequestTests { .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) { try { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java index 03234771e2..9dcee73f90 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java @@ -200,6 +200,18 @@ class LifecycleTests { 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() { DockerApi docker = mock(DockerApi.class); ImageApi imageApi = mock(ImageApi.class); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json new file mode 100644 index 0000000000..07d53944b6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json @@ -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" + ] + } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc index baa70ab7da..9e5b64cf7e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc @@ -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. | +| `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. @@ -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 ---- +[[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]] === 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: [source,groovy,indent=0,subs="verbatim,attributes",role="primary"] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle new file mode 100644 index 0000000000..b0f0305c8e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle @@ -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" } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle.kts new file mode 100644 index 0000000000..43f37b86e2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle.kts @@ -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") { + 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").buildCache.asCache().volume.name) + println("launchCache=" + tasks.getByName("bootBuildImage").launchCache.asCache().volume.name) + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java index b986dd6ff1..db58d4b072 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java @@ -60,6 +60,7 @@ import org.springframework.util.StringUtils; * @author Scott Frederick * @author Rafael Ceccone * @author Jeroen Meijer + * @author Julian Liebig * @since 2.3.0 */ public class BootBuildImage extends DefaultTask { @@ -98,6 +99,10 @@ public class BootBuildImage extends DefaultTask { private final ListProperty tags; + private final CacheSpec buildCache = new CacheSpec(); + + private final CacheSpec launchCache = new CacheSpec(); + private final DockerSpec docker = new DockerSpec(); public BootBuildImage() { @@ -436,6 +441,62 @@ public class BootBuildImage extends DefaultTask { 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 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 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. * @return docker configuration. @@ -499,6 +560,7 @@ public class BootBuildImage extends DefaultTask { request = customizeBuildpacks(request); request = customizeBindings(request); request = customizeTags(request); + request = customizeCaches(request); request = request.withNetwork(this.network); return request; } @@ -576,6 +638,16 @@ public class BootBuildImage extends DefaultTask { 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() { return this.targetJavaVersion.get().getMajorVersion() + ".*"; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java new file mode 100644 index 0000000000..21ca806b4e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java @@ -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 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; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java index cc0330e423..06ace0485c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java @@ -316,6 +316,14 @@ class PackagingDocumentationTests { .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 { try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) { jar.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java index ee85a516e1..c47a7cfe02 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java @@ -40,7 +40,10 @@ import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; 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.VolumeName; import org.springframework.boot.buildpack.platform.io.FilePermissions; import org.springframework.boot.gradle.junit.GradleCompatibility; 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("Network status: HTTP/2 200"); assertThat(result.getOutput()).contains("---> Test Info buildpack done"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -91,7 +94,7 @@ class BootBuildImageIntegrationTests { File buildLibs = new File(this.gradleBuild.getProjectDir(), "build/libs"); assertThat(buildLibs.listFiles()) .containsExactly(new File(buildLibs, this.gradleBuild.getProjectDir().getName() + ".war")); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -107,7 +110,7 @@ class BootBuildImageIntegrationTests { File buildLibs = new File(this.gradleBuild.getProjectDir(), "build/libs"); assertThat(buildLibs.listFiles()) .containsExactly(new File(buildLibs, this.gradleBuild.getProjectDir().getName() + ".war")); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -119,7 +122,7 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("example/test-image-name"); assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack done"); - removeImage("example/test-image-name"); + removeImages("example/test-image-name"); } @TestTemplate @@ -131,7 +134,7 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("example/test-image-custom"); assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack done"); - removeImage("example/test-image-custom"); + removeImages("example/test-image-custom"); } @TestTemplate @@ -146,7 +149,7 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("example/test-image-cmd"); assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack done"); - removeImage("example/test-image-cmd"); + removeImages("example/test-image-cmd"); } @TestTemplate @@ -160,7 +163,7 @@ class BootBuildImageIntegrationTests { result = this.gradleBuild.build("bootBuildImage", "--pullPolicy=IF_NOT_PRESENT"); assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(result.getOutput()).doesNotContain("Pulled builder image").doesNotContain("Pulled run image"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -173,7 +176,7 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("---> Test Info buildpack building") .contains("---> Test Info buildpack done"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -187,7 +190,7 @@ class BootBuildImageIntegrationTests { assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("---> Hello World buildpack"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -202,7 +205,7 @@ class BootBuildImageIntegrationTests { assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("---> Hello World buildpack"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -215,7 +218,7 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("---> Test Info buildpack building") .contains("---> Test Info buildpack done"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -232,7 +235,7 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("binding: certificates/test1.crt=---certificate one---"); assertThat(result.getOutput()).contains("binding: certificates/test2.crt=---certificate two---"); assertThat(result.getOutput()).contains("---> Test Info buildpack done"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -246,8 +249,7 @@ class BootBuildImageIntegrationTests { 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"); + removeImages(projectName, "example.com/myapp:latest"); } @TestTemplate @@ -260,7 +262,7 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("docker.io/library/" + projectName); assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("---> Test Info buildpack done"); - removeImage(projectName); + removeImages(projectName); } @TestTemplate @@ -274,7 +276,21 @@ class BootBuildImageIntegrationTests { assertThat(result.getOutput()).contains("---> Test Info buildpack building"); assertThat(result.getOutput()).contains("Network status: curl failed"); 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 @@ -325,6 +341,14 @@ class BootBuildImageIntegrationTests { .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 { File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example"); examplePackage.mkdirs(); @@ -447,9 +471,18 @@ class BootBuildImageIntegrationTests { } } - private void removeImage(String name) throws IOException { - ImageReference imageReference = ImageReference.of(name); - new DockerApi().image().remove(imageReference, false); + private void removeImages(String... names) throws IOException { + ImageApi imageApi = new DockerApi().image(); + 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); + } } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle new file mode 100644 index 0000000000..e1b771bce1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle @@ -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" + } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle new file mode 100644 index 0000000000..22da9fc0b6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle @@ -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" + } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc index f830694f34..88e0d2960c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc @@ -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. | +| `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. @@ -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 ---- +[[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]] === 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: [source,xml,indent=0,subs="verbatim,attributes",tabsize=4] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/caches-pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/caches-pom.xml new file mode 100644 index 0000000000..52fa51ee04 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/caches-pom.xml @@ -0,0 +1,27 @@ + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + cache-${project.artifactId}.build + + + + + cache-${project.artifactId}.launch + + + + + + + + + \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java index ce3b9a103d..cae1331f7f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java @@ -27,8 +27,10 @@ import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; 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.ImageReference; +import org.springframework.boot.buildpack.platform.docker.type.VolumeName; import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; 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 void failsWhenBuildImageIsInvokedOnMultiModuleProjectWithBuildImageGoal(MavenBuild mavenBuild) { 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")); } + @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) { StringBuilder name = new StringBuilder(); 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); + } + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/pom.xml new file mode 100644 index 0000000000..e2442a8bfa --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + org.springframework.boot.maven.it + build-image-caches-multiple + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + build-image + + + + projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.1 + + + build-cache-volume1 + + + build-cache-volume2 + + + + + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/src/main/java/org/test/SampleApplication.java new file mode 100644 index 0000000000..e964724dea --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.test; + +public class SampleApplication { + + public static void main(String[] args) throws Exception { + System.out.println("Launched"); + synchronized(args) { + args.wait(); // Prevent exit + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml new file mode 100644 index 0000000000..771cb54836 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + org.springframework.boot.maven.it + build-image-caches + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + build-image + + + + projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.1 + + + build-cache-volume + + + + + launch-cache-volume + + + + + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java new file mode 100644 index 0000000000..e964724dea --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.test; + +public class SampleApplication { + + public static void main(String[] args) throws Exception { + System.out.println("Launched"); + synchronized(args) { + args.wait(); // Prevent exit + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java new file mode 100644 index 0000000000..491deabe28 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java @@ -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; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java index b2f7692d40..634989c357 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java @@ -41,6 +41,7 @@ import org.springframework.util.StringUtils; * @author Scott Frederick * @author Jeroen Meijer * @author Rafael Ceccone + * @author Julian Liebig * @since 2.3.0 */ public class Image { @@ -69,6 +70,10 @@ public class Image { List tags; + CacheInfo buildCache; + + CacheInfo launchCache; + /** * The name of the created image. * @return the image name @@ -212,6 +217,12 @@ public class Image { if (!CollectionUtils.isEmpty(this.tags)) { 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; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java index bef9d7d111..c210cb459b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java @@ -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.BuildpackReference; +import org.springframework.boot.buildpack.platform.build.Cache; import org.springframework.boot.buildpack.platform.build.PullPolicy; import org.springframework.boot.buildpack.platform.docker.type.Binding; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.Owner; import org.springframework.boot.buildpack.platform.io.TarArchive; +import org.springframework.boot.maven.CacheInfo.VolumeCacheInfo; import static org.assertj.core.api.Assertions.assertThat; 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")); } + @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() { return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile", "jar", null, new DefaultArtifactHandler());