diff --git a/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc b/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc index 8e3b4bbfaded..9cb7acbc38ee 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc +++ b/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/packaging-oci-image.adoc @@ -179,7 +179,7 @@ Buildpack references must be in one of the following forms: | None, indicating the builder should use the buildpacks included in it. | `bindings` -| +| `--bindings` a|https://docs.docker.com/storage/bind-mounts/[Volume bind mounts] that should be mounted to the builder container when building the image. The bindings will be passed unparsed and unvalidated to Docker when creating the builder container. Bindings must be in one of the following forms: @@ -192,6 +192,9 @@ Where `` can contain: * `ro` to mount the volume as read-only in the container * `rw` to mount the volume as readable and writable in the container * `volume-opt=key=value` to specify key-value pairs consisting of an option name and its value + +Bindings provided using the `--bindings` command-line option are added to those configured in the build script. +When a command-line binding shares its container destination path with a configured binding, the command-line binding takes precedence. | | `network` diff --git a/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java index 078180fafae4..c5384cc4c85e 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java +++ b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java @@ -17,10 +17,13 @@ package org.springframework.boot.gradle.tasks.bundling; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import org.gradle.api.Action; import org.gradle.api.DefaultTask; @@ -108,6 +111,20 @@ public BootBuildImage() { getSecurityOptions().convention((Iterable) null); getEffectiveEnvironment().putAll(getEnvironment()); getEffectiveEnvironment().putAll(getEnvironmentFromCommandLine().map(BootBuildImage::asMap)); + getEffectiveBindings().set(getBindings().zip(getBindingsFromCommandLine(), BootBuildImage::mergeBindings)); + } + + private static List mergeBindings(List bindings, List bindingsFromCommandLine) { + Set commandLineContainerDestinationPaths = bindingsFromCommandLine.stream() + .map((binding) -> Binding.of(binding).getContainerDestinationPath()) + .collect(Collectors.toSet()); + List merged = new ArrayList<>(); + bindings.stream() + .filter((binding) -> !commandLineContainerDestinationPaths + .contains(Binding.of(binding).getContainerDestinationPath())) + .forEach(merged::add); + merged.addAll(bindingsFromCommandLine); + return merged; } private static Map asMap(List variables) { @@ -252,9 +269,23 @@ public void setPullPolicy(String pullPolicy) { * image. * @return the bindings */ + @Internal + public abstract ListProperty getBindings(); + + /** + * Returns the volume bindings contributed from the command line. Added bindings take + * precedence over configured bindings that share the same container destination path. + * @return the bindings from the command line + * @since 4.1.1 + */ + @Internal + @Option(option = "bindings", description = "Volume bind mounts that will be mounted to the builder container. " + + "Can be specified multiple times.") + abstract ListProperty getBindingsFromCommandLine(); + @Input @Optional - public abstract ListProperty getBindings(); + abstract ListProperty getEffectiveBindings(); /** * Returns the tags that will be created for the built image. @@ -479,7 +510,7 @@ private BuildRequest customizeBuildpacks(BuildRequest request) { } private BuildRequest customizeBindings(BuildRequest request) { - List bindings = getBindings().getOrNull(); + List bindings = getEffectiveBindings().getOrNull(); if (!CollectionUtils.isEmpty(bindings)) { return request.withBindings(bindings.stream().map(Binding::of).toList()); } diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java index 9a917d023825..846dbf19c170 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java @@ -291,6 +291,30 @@ void whenIndividualEntriesAreAddedToBindingsThenRequestHasBindings() { .containsExactly(Binding.of("host-src:container-dest:ro"), Binding.of("volume-name:container-dest:rw")); } + @Test + void whenBindingsAreSetOnTheCommandLineAndNoneAreConfiguredThenRequestHasCommandLineBindings() { + this.buildImage.getBindingsFromCommandLine().add("host-src:container-a:ro"); + this.buildImage.getBindingsFromCommandLine().add("volume-name:container-b:rw"); + assertThat(this.buildImage.createRequest().getBindings()).containsExactly(Binding.of("host-src:container-a:ro"), + Binding.of("volume-name:container-b:rw")); + } + + @Test + void whenBindingsAreSetOnTheCommandLineThenTheyAreAddedToConfiguredBindings() { + this.buildImage.getBindings().set(Arrays.asList("host-src:container-a:ro")); + this.buildImage.getBindingsFromCommandLine().add("volume-name:container-b:rw"); + assertThat(this.buildImage.createRequest().getBindings()).containsExactly(Binding.of("host-src:container-a:ro"), + Binding.of("volume-name:container-b:rw")); + } + + @Test + void whenBindingsFromTheCommandLineShareContainerPathWithConfiguredBindingThenCommandLineTakesPrecedence() { + this.buildImage.getBindings().set(Arrays.asList("host-src:container-a:ro", "volume-name:container-b:ro")); + this.buildImage.getBindingsFromCommandLine().add("other-volume:container-b:rw"); + assertThat(this.buildImage.createRequest().getBindings()).containsExactly(Binding.of("host-src:container-a:ro"), + Binding.of("other-volume:container-b:rw")); + } + @Test void whenNetworkIsConfiguredThenRequestHasNetwork() { this.buildImage.getNetwork().set("test"); diff --git a/build-plugin/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc b/build-plugin/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc index e099dcef4ff3..1543b37e3ca4 100644 --- a/build-plugin/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc +++ b/build-plugin/spring-boot-maven-plugin/src/docs/antora/modules/maven-plugin/pages/build-image.adoc @@ -195,7 +195,8 @@ Buildpack references must be in one of the following forms: * Buildpack in an OCI image - `[docker://]/[:][@]` | None, indicating the builder should use the buildpacks included in it. -| `bindings` +| `bindings` + +(`spring-boot.build-image.bindings`) a|https://docs.docker.com/storage/bind-mounts/[Volume bind mounts] that should be mounted to the builder container when building the image. The bindings will be passed unparsed and unvalidated to Docker when creating the builder container. Bindings must be in one of the following forms: @@ -208,6 +209,9 @@ Where `` can contain: * `ro` to mount the volume as read-only in the container * `rw` to mount the volume as readable and writable in the container * `volume-opt=key=value` to specify key-value pairs consisting of an option name and its value + +Bindings provided on the command line are added to the bindings configured in the `pom.xml`. +When a command-line binding shares its container destination path with a configured binding, the command-line binding takes precedence. | | `network` + (`spring-boot.build-image.network`) diff --git a/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java b/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java index 24b07f85b932..a0c3443ee954 100644 --- a/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java +++ b/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.io.OutputStream; import java.time.Duration; +import java.util.Arrays; import java.util.Collections; import java.util.function.Consumer; import java.util.function.Function; @@ -191,6 +192,15 @@ public abstract class BuildImageMojo extends AbstractPackagerMojo { @Parameter(property = "spring-boot.build-image.imagePlatform") @Nullable String imagePlatform; + /** + * Alias for {@link Image#bindings} to support configuration through command-line + * property. + * @since 4.1.1 + */ + @Parameter(property = "spring-boot.build-image.bindings") + @SuppressWarnings("NullAway") // maven-maven-plugin can't handle annotated arrays + String[] bindings; + /** * Docker configuration options. * @since 2.4.0 @@ -306,6 +316,9 @@ private BuildRequest getBuildRequest(Libraries libraries) { if (image.imagePlatform == null && this.imagePlatform != null) { image.setImagePlatform(this.imagePlatform); } + if (this.bindings != null && this.bindings.length > 0) { + image.addBindings(Arrays.asList(this.bindings)); + } return customize(image.getBuildRequest(this.project.getArtifact(), content)); } diff --git a/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java b/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java index 42e7f7ca4cdb..141f2c789ce4 100644 --- a/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java +++ b/build-plugin/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java @@ -16,9 +16,12 @@ package org.springframework.boot.maven; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; +import java.util.stream.Collectors; import org.apache.maven.artifact.Artifact; import org.jspecify.annotations.Nullable; @@ -188,6 +191,26 @@ void setPublish(@Nullable Boolean publish) { this.publish = publish; } + /** + * Adds the given bindings to the existing bindings, with the added bindings taking + * precedence over existing bindings that share the same container destination path. + * @param bindings the bindings to add + */ + void addBindings(List bindings) { + List merged = new ArrayList<>(); + if (this.bindings != null) { + Set addedContainerDestinationPaths = bindings.stream() + .map((binding) -> Binding.of(binding).getContainerDestinationPath()) + .collect(Collectors.toSet()); + this.bindings.stream() + .filter((binding) -> !addedContainerDestinationPaths + .contains(Binding.of(binding).getContainerDestinationPath())) + .forEach(merged::add); + } + merged.addAll(bindings); + this.bindings = merged; + } + /** * Returns the network the build container will connect to. * @return the network diff --git a/build-plugin/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java b/build-plugin/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java index a6a2b4d55bac..99a174629c8f 100644 --- a/build-plugin/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java +++ b/build-plugin/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java @@ -180,6 +180,35 @@ void getBuildRequestWhenHasBindingsUsesBindings() { Binding.of("volume-name:container-dest:rw")); } + @Test + void getBuildRequestWhenAddBindingsAndHasNoBindingsUsesAddedBindings() { + Image image = new Image(); + image.addBindings(Arrays.asList("host-src:container-a:ro", "volume-name:container-b:rw")); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getBindings()).containsExactly(Binding.of("host-src:container-a:ro"), + Binding.of("volume-name:container-b:rw")); + } + + @Test + void getBuildRequestWhenAddBindingsAddsToExistingBindings() { + Image image = new Image(); + image.bindings = Arrays.asList("host-src:container-a:ro"); + image.addBindings(Arrays.asList("volume-name:container-b:rw")); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getBindings()).containsExactly(Binding.of("host-src:container-a:ro"), + Binding.of("volume-name:container-b:rw")); + } + + @Test + void getBuildRequestWhenAddBindingsWithMatchingContainerPathOverridesExistingBinding() { + Image image = new Image(); + image.bindings = Arrays.asList("host-src:container-a:ro", "volume-name:container-b:ro"); + image.addBindings(Arrays.asList("other-volume:container-b:rw")); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getBindings()).containsExactly(Binding.of("host-src:container-a:ro"), + Binding.of("other-volume:container-b:rw")); + } + @Test void getBuildRequestWhenNetworkUsesNetwork() { Image image = new Image(); diff --git a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Binding.java b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Binding.java index a66e59da429d..0f6d92d7b26e 100644 --- a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Binding.java +++ b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Binding.java @@ -77,8 +77,9 @@ public boolean usesSensitiveContainerPath() { /** * Returns the container destination path. * @return the container destination path + * @since 4.1.1 */ - String getContainerDestinationPath() { + public String getContainerDestinationPath() { List parts = getParts(); Assert.state(parts.size() >= 2, () -> "Expected 2 or more parts, but found %d".formatted(parts.size())); return parts.get(1);