From dc3634628507fcf9e2a842c8d4318bdb974f2351 Mon Sep 17 00:00:00 2001 From: Julian Liebig Date: Mon, 11 Oct 2021 17:59:45 +0200 Subject: [PATCH 1/2] Add option to customize cache volume names when building an image This commit adds configuration to the Maven and Gradle plugins to allow specifying the names of build and launch cache volumes provided to the CNB builder. See gh-28292 --- .../platform/build/BuildRequest.java | 73 +++++++++++++++---- .../buildpack/platform/build/Lifecycle.java | 10 ++- .../docs/asciidoc/packaging-oci-image.adoc | 5 ++ .../boot-build-image-cacheVolumeNames.gradle | 23 ++++++ ...ot-build-image-cacheVolumeNames.gradle.kts | 21 ++++++ .../gradle/tasks/bundling/BootBuildImage.java | 46 ++++++++++++ .../docs/asciidoc/packaging-oci-image.adoc | 4 + .../org/springframework/boot/maven/Image.java | 6 ++ 8 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts 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..78c1547a06 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,8 @@ public class BuildRequest { private final List tags; + private final Map cacheVolumeNames; + BuildRequest(ImageReference name, Function applicationContent) { Assert.notNull(name, "Name must not be null"); Assert.notNull(applicationContent, "ApplicationContent must not be null"); @@ -91,12 +94,13 @@ public class BuildRequest { this.bindings = Collections.emptyList(); this.network = null; this.tags = Collections.emptyList(); + this.cacheVolumeNames = Collections.emptyMap(); } 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, Map cacheVolumeNames) { this.name = name; this.applicationContent = applicationContent; this.builder = builder; @@ -111,6 +115,7 @@ public class BuildRequest { this.bindings = bindings; this.network = network; this.tags = tags; + this.cacheVolumeNames = cacheVolumeNames; } /** @@ -122,7 +127,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.cacheVolumeNames); } /** @@ -133,7 +138,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.cacheVolumeNames); } /** @@ -145,7 +150,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.cacheVolumeNames); } /** @@ -161,7 +166,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.cacheVolumeNames); } /** @@ -175,7 +180,7 @@ 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.cacheVolumeNames); } /** @@ -186,7 +191,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.cacheVolumeNames); } /** @@ -197,7 +202,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.cacheVolumeNames); } /** @@ -208,7 +213,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.cacheVolumeNames); } /** @@ -219,7 +224,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.cacheVolumeNames); } /** @@ -243,7 +248,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.cacheVolumeNames); } /** @@ -267,7 +272,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.cacheVolumeNames); } /** @@ -279,7 +284,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.cacheVolumeNames); } /** @@ -301,7 +306,39 @@ 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.cacheVolumeNames); + } + + /** + * Return a new {@link BuildRequest} with an additional cache volume name. + * @param type the cache volume type + * @param name the cache volume name + * @return an updated build request + */ + public BuildRequest withCacheVolumeName(String type, String name) { + Assert.hasText(type, "Type must not be empty"); + Assert.state((type.equals("build") || type.equals("launch")), "Type must be either 'build' or 'launch'"); + Assert.hasText(name, "Name must not be empty"); + Map cacheVolumeNames = new LinkedHashMap<>(this.cacheVolumeNames); + cacheVolumeNames.put(type, name); + 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, Collections.unmodifiableMap(cacheVolumeNames)); + } + + /** + * Return a new {@link BuildRequest} with additional cache volume names. + * @param entries the additional cache volume names + * @return an updated build request + */ + public BuildRequest withCacheVolumeNames(Map entries) { + Assert.notNull(entries, "Entries must not be null"); + Assert.state(!entries.isEmpty(), "Entries must not be empty"); + BuildRequest request = null; + for (Map.Entry entry : entries.entrySet()) { + request = withCacheVolumeName(entry.getKey(), entry.getValue()); + } + return request; } /** @@ -421,6 +458,14 @@ public class BuildRequest { return this.tags; } + /** + * Return the custom cache volume names that should be used by the lifecycle. + * @return the cache volume names + */ + public Map getCacheVolumeNames() { + return this.cacheVolumeNames; + } + /** * 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/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..39474fba3c 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,8 +87,8 @@ 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 = createCacheVolumeName(request, "build"); + this.launchCacheVolume = createCacheVolumeName(request, "launch"); } protected VolumeName createRandomVolumeName(String prefix) { @@ -95,7 +96,10 @@ class Lifecycle implements Closeable { } private VolumeName createCacheVolumeName(BuildRequest request, String suffix) { - return VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", suffix, 6); + if (!request.getCacheVolumeNames().isEmpty() && request.getCacheVolumeNames().containsKey(suffix)) { + return VolumeName.of(request.getCacheVolumeNames().get(suffix)); + } + 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-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..6e58839a1e 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,11 @@ 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. | +| `cacheVolumeNames` +| +| Cache volume names that should be used by the builder instead of generating random names. +| + |=== NOTE: The plugin detects the target Java compatibility of the project using the JavaPlugin's `targetCompatibility` property. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle new file mode 100644 index 0000000000..757bad180d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle @@ -0,0 +1,23 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{gradle-project-version}' +} + +bootJar { + mainClass = 'com.example.ExampleApplication' +} + +// tag::cacheVolumeNames[] +bootBuildImage { + cacheVolumeNames = [ + "build": "example-build-cachevol", + "launch": "example-launch-cachevol" + ] +} +// end::cacheVolumeNames[] + +task bootBuildImageCacheVolumeNames { + doFirst { + bootBuildImage.cacheVolumeNames.each { type, name -> println "$type=$name" } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts new file mode 100644 index 0000000000..49d0da7d1f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts @@ -0,0 +1,21 @@ +import org.springframework.boot.gradle.tasks.bundling.BootBuildImage + +plugins { + java + id("org.springframework.boot") version "{gradle-project-version}" +} + +// tag::cacheVolumeNames[] +tasks.getByName("bootBuildImage") { + cacheVolumeNames = mapOf("build" to "example-build-cachevol", + "launch" to "example-launch-cachevol") +} +// end::cacheVolumeNames[] + +tasks.register("bootBuildImageEnvironment") { + doFirst { + for((type, name) in tasks.getByName("bootBuildImage").cacheVolumeNames) { + print(type + "=" + 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..f800aa3646 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,8 @@ public class BootBuildImage extends DefaultTask { private final ListProperty tags; + private Map cacheVolumeNames = new HashMap<>(); + private final DockerSpec docker = new DockerSpec(); public BootBuildImage() { @@ -417,6 +420,41 @@ public class BootBuildImage extends DefaultTask { this.tags.addAll(tags); } + /** + * Returns the cache volume names that will be used when building the image. + * @return the cache volume names + */ + @Input + @Optional + public Map getCacheVolumeNames() { + return this.cacheVolumeNames; + } + + /** + * Sets the cache volume names that will be used when building the image. + * @param cacheVolumeNames the cache volume names + */ + public void setCacheVolumeNames(Map cacheVolumeNames) { + this.cacheVolumeNames = cacheVolumeNames; + } + + /** + * Add an entry to cache volume names that will be used when building the image. + * @param type the type of the entry + * @param name the name of the entry + */ + public void cacheVolumeName(String type, String name) { + this.cacheVolumeNames.put(type, name); + } + + /** + * Adds entries to cache volume names that will be used when building the image. + * @param entries the entries to add to cache volume names + */ + public void cacheVolumeNames(Map entries) { + this.cacheVolumeNames.putAll(entries); + } + /** * Returns the network the build container will connect to. * @return the network @@ -499,6 +537,7 @@ public class BootBuildImage extends DefaultTask { request = customizeBuildpacks(request); request = customizeBindings(request); request = customizeTags(request); + request = customizeCacheVolumeNames(request); request = request.withNetwork(this.network); return request; } @@ -576,6 +615,13 @@ public class BootBuildImage extends DefaultTask { return request; } + private BuildRequest customizeCacheVolumeNames(BuildRequest request) { + if (this.cacheVolumeNames != null && !this.cacheVolumeNames.isEmpty()) { + request = request.withCacheVolumeNames(this.cacheVolumeNames); + } + return request; + } + private String translateTargetJavaVersion() { return this.targetJavaVersion.get().getMajorVersion() + ".*"; } 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..3ab524bffe 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,10 @@ The value supplied will be passed unvalidated to Docker when creating the builde | One or more additional tags to apply to the generated image. | +| `cacheVolumeNames` +| Cache volume names that should be used by the builder instead of generating random names. +| + |=== NOTE: The plugin detects the target Java compatibility of the project using the compiler's plugin configuration or the `maven.compiler.target` property. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/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..c140d70d59 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,8 @@ public class Image { List tags; + Map cacheVolumeNames; + /** * The name of the created image. * @return the image name @@ -212,6 +215,9 @@ public class Image { if (!CollectionUtils.isEmpty(this.tags)) { request = request.withTags(this.tags.stream().map(ImageReference::of).collect(Collectors.toList())); } + if (this.cacheVolumeNames != null && !this.cacheVolumeNames.isEmpty()) { + request = request.withCacheVolumeNames(this.cacheVolumeNames); + } return request; } From 871468931fd6b600fdbe9d38b42d24fdf806a046 Mon Sep 17 00:00:00 2001 From: Scott Frederick Date: Thu, 14 Oct 2021 14:55:04 -0500 Subject: [PATCH 2/2] Polish "Add option to customize cache volume names when building an image" See gh-28292 --- .../platform/build/BuildRequest.java | 87 ++++++------ .../boot/buildpack/platform/build/Cache.java | 125 ++++++++++++++++++ .../buildpack/platform/build/Lifecycle.java | 28 +++- .../platform/build/BuildRequestTests.java | 34 ++++- .../platform/build/LifecycleTests.java | 12 ++ .../lifecycle-creator-cache-volumes.json | 37 ++++++ .../docs/asciidoc/packaging-oci-image.adoc | 29 +++- .../boot-build-image-cacheVolumeNames.gradle | 23 ---- ...ot-build-image-cacheVolumeNames.gradle.kts | 21 --- .../packaging/boot-build-image-caches.gradle | 30 +++++ .../boot-build-image-caches.gradle.kts | 28 ++++ .../gradle/tasks/bundling/BootBuildImage.java | 106 +++++++++------ .../boot/gradle/tasks/bundling/CacheSpec.java | 94 +++++++++++++ .../docs/PackagingDocumentationTests.java | 8 ++ .../BootBuildImageIntegrationTests.java | 71 +++++++--- ...onTests-buildsImageWithVolumeCaches.gradle | 21 +++ ...s-failsWhenCachesAreConfiguredTwice.gradle | 19 +++ .../docs/asciidoc/packaging-oci-image.adoc | 23 +++- .../maven/packaging-oci-image/caches-pom.xml | 27 ++++ .../boot/maven/BuildImageTests.java | 28 ++++ .../build-image-caches-multiple/pom.xml | 42 ++++++ .../main/java/org/test/SampleApplication.java | 28 ++++ .../projects/build-image-caches/pom.xml | 44 ++++++ .../main/java/org/test/SampleApplication.java | 28 ++++ .../springframework/boot/maven/CacheInfo.java | 72 ++++++++++ .../org/springframework/boot/maven/Image.java | 11 +- .../boot/maven/ImageTests.java | 18 +++ 27 files changed, 937 insertions(+), 157 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json delete mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle delete mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-caches.gradle.kts create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/caches-pom.xml create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/pom.xml create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches-multiple/src/main/java/org/test/SampleApplication.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java 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 78c1547a06..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 @@ -75,7 +75,9 @@ public class BuildRequest { private final List tags; - private final Map cacheVolumeNames; + private final Cache buildCache; + + private final Cache launchCache; BuildRequest(ImageReference name, Function applicationContent) { Assert.notNull(name, "Name must not be null"); @@ -94,13 +96,14 @@ public class BuildRequest { this.bindings = Collections.emptyList(); this.network = null; this.tags = Collections.emptyList(); - this.cacheVolumeNames = Collections.emptyMap(); + 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, Map cacheVolumeNames) { + List bindings, String network, List tags, Cache buildCache, Cache launchCache) { this.name = name; this.applicationContent = applicationContent; this.builder = builder; @@ -115,7 +118,8 @@ public class BuildRequest { this.bindings = bindings; this.network = network; this.tags = tags; - this.cacheVolumeNames = cacheVolumeNames; + this.buildCache = buildCache; + this.launchCache = launchCache; } /** @@ -127,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.cacheVolumeNames); + this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -138,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.cacheVolumeNames); + this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -150,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.cacheVolumeNames); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -166,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.cacheVolumeNames); + this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -180,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.cacheVolumeNames); + this.publish, this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, + this.launchCache); } /** @@ -191,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.cacheVolumeNames); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -202,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.cacheVolumeNames); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -213,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.cacheVolumeNames); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -224,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.cacheVolumeNames); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -248,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.cacheVolumeNames); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -272,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.cacheVolumeNames); + this.network, this.tags, this.buildCache, this.launchCache); } /** @@ -284,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, this.cacheVolumeNames); + network, this.tags, this.buildCache, this.launchCache); } /** @@ -306,39 +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.cacheVolumeNames); + this.network, tags, this.buildCache, this.launchCache); } /** - * Return a new {@link BuildRequest} with an additional cache volume name. - * @param type the cache volume type - * @param name the cache volume name + * Return a new {@link BuildRequest} with an updated build cache. + * @param buildCache the build cache * @return an updated build request */ - public BuildRequest withCacheVolumeName(String type, String name) { - Assert.hasText(type, "Type must not be empty"); - Assert.state((type.equals("build") || type.equals("launch")), "Type must be either 'build' or 'launch'"); - Assert.hasText(name, "Name must not be empty"); - Map cacheVolumeNames = new LinkedHashMap<>(this.cacheVolumeNames); - cacheVolumeNames.put(type, name); + 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, Collections.unmodifiableMap(cacheVolumeNames)); + this.network, this.tags, buildCache, this.launchCache); } /** - * Return a new {@link BuildRequest} with additional cache volume names. - * @param entries the additional cache volume names + * Return a new {@link BuildRequest} with an updated launch cache. + * @param launchCache the cache * @return an updated build request */ - public BuildRequest withCacheVolumeNames(Map entries) { - Assert.notNull(entries, "Entries must not be null"); - Assert.state(!entries.isEmpty(), "Entries must not be empty"); - BuildRequest request = null; - for (Map.Entry entry : entries.entrySet()) { - request = withCacheVolumeName(entry.getKey(), entry.getValue()); - } - return 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); } /** @@ -459,11 +456,19 @@ public class BuildRequest { } /** - * Return the custom cache volume names that should be used by the lifecycle. - * @return the cache volume names + * Return the custom build cache that should be used by the lifecycle. + * @return the build cache */ - public Map getCacheVolumeNames() { - return this.cacheVolumeNames; + 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; } /** 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 39474fba3c..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 @@ -87,18 +87,36 @@ 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 createCacheVolumeName(BuildRequest request, String suffix) { - if (!request.getCacheVolumeNames().isEmpty() && request.getCacheVolumeNames().containsKey(suffix)) { - return VolumeName.of(request.getCacheVolumeNames().get(suffix)); + 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); } 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 6e58839a1e..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,10 +181,15 @@ 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. | -| `cacheVolumeNames` +| `buildCache` | -| Cache volume names that should be used by the builder instead of generating random names. +| 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. |=== @@ -384,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-cacheVolumeNames.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle deleted file mode 100644 index 757bad180d..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle +++ /dev/null @@ -1,23 +0,0 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '{gradle-project-version}' -} - -bootJar { - mainClass = 'com.example.ExampleApplication' -} - -// tag::cacheVolumeNames[] -bootBuildImage { - cacheVolumeNames = [ - "build": "example-build-cachevol", - "launch": "example-launch-cachevol" - ] -} -// end::cacheVolumeNames[] - -task bootBuildImageCacheVolumeNames { - doFirst { - bootBuildImage.cacheVolumeNames.each { type, name -> println "$type=$name" } - } -} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts deleted file mode 100644 index 49d0da7d1f..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-cacheVolumeNames.gradle.kts +++ /dev/null @@ -1,21 +0,0 @@ -import org.springframework.boot.gradle.tasks.bundling.BootBuildImage - -plugins { - java - id("org.springframework.boot") version "{gradle-project-version}" -} - -// tag::cacheVolumeNames[] -tasks.getByName("bootBuildImage") { - cacheVolumeNames = mapOf("build" to "example-build-cachevol", - "launch" to "example-launch-cachevol") -} -// end::cacheVolumeNames[] - -tasks.register("bootBuildImageEnvironment") { - doFirst { - for((type, name) in tasks.getByName("bootBuildImage").cacheVolumeNames) { - print(type + "=" + name) - } - } -} 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 f800aa3646..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 @@ -99,7 +99,9 @@ public class BootBuildImage extends DefaultTask { private final ListProperty tags; - private Map cacheVolumeNames = new HashMap<>(); + private final CacheSpec buildCache = new CacheSpec(); + + private final CacheSpec launchCache = new CacheSpec(); private final DockerSpec docker = new DockerSpec(); @@ -420,41 +422,6 @@ public class BootBuildImage extends DefaultTask { this.tags.addAll(tags); } - /** - * Returns the cache volume names that will be used when building the image. - * @return the cache volume names - */ - @Input - @Optional - public Map getCacheVolumeNames() { - return this.cacheVolumeNames; - } - - /** - * Sets the cache volume names that will be used when building the image. - * @param cacheVolumeNames the cache volume names - */ - public void setCacheVolumeNames(Map cacheVolumeNames) { - this.cacheVolumeNames = cacheVolumeNames; - } - - /** - * Add an entry to cache volume names that will be used when building the image. - * @param type the type of the entry - * @param name the name of the entry - */ - public void cacheVolumeName(String type, String name) { - this.cacheVolumeNames.put(type, name); - } - - /** - * Adds entries to cache volume names that will be used when building the image. - * @param entries the entries to add to cache volume names - */ - public void cacheVolumeNames(Map entries) { - this.cacheVolumeNames.putAll(entries); - } - /** * Returns the network the build container will connect to. * @return the network @@ -474,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. @@ -537,7 +560,7 @@ public class BootBuildImage extends DefaultTask { request = customizeBuildpacks(request); request = customizeBindings(request); request = customizeTags(request); - request = customizeCacheVolumeNames(request); + request = customizeCaches(request); request = request.withNetwork(this.network); return request; } @@ -615,9 +638,12 @@ public class BootBuildImage extends DefaultTask { return request; } - private BuildRequest customizeCacheVolumeNames(BuildRequest request) { - if (this.cacheVolumeNames != null && !this.cacheVolumeNames.isEmpty()) { - request = request.withCacheVolumeNames(this.cacheVolumeNames); + 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; } 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 3ab524bffe..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,10 +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. | -| `cacheVolumeNames` +| `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. @@ -344,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 c140d70d59..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 @@ -70,7 +70,9 @@ public class Image { List tags; - Map cacheVolumeNames; + CacheInfo buildCache; + + CacheInfo launchCache; /** * The name of the created image. @@ -215,8 +217,11 @@ public class Image { if (!CollectionUtils.isEmpty(this.tags)) { request = request.withTags(this.tags.stream().map(ImageReference::of).collect(Collectors.toList())); } - if (this.cacheVolumeNames != null && !this.cacheVolumeNames.isEmpty()) { - request = request.withCacheVolumeNames(this.cacheVolumeNames); + 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());