From f6dae1397dea4cfa764cfa79a902ffcbfad3fbb9 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Tue, 22 Apr 2025 15:48:37 -0700 Subject: [PATCH] Add application directory layer to ephemeral builder for Podman support Update `EphemeralBuilder` so that it adds an additional layer that containing an empty application (aka workspace) directory owned by the build user. Prior to this commit, the directory was only bound. This could cause issues on Podman where, unlike Docker, the bound directory is owned by `root`. Fixes gh-45233 --- .../buildpack/platform/build/Builder.java | 37 ++++++++------- .../platform/build/EphemeralBuilder.java | 45 +++++++++++++------ .../buildpack/platform/build/Lifecycle.java | 6 ++- .../platform/build/EphemeralBuilderTests.java | 26 +++++++---- 4 files changed, 75 insertions(+), 39 deletions(-) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java index 2549144662c..56343cd2ce9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -29,6 +29,7 @@ import org.springframework.boot.buildpack.platform.docker.configuration.DockerCo import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost; import org.springframework.boot.buildpack.platform.docker.transport.DockerEngineException; import org.springframework.boot.buildpack.platform.docker.type.Image; +import org.springframework.boot.buildpack.platform.docker.type.ImageArchive; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.IOBiConsumer; import org.springframework.boot.buildpack.platform.io.TarArchive; @@ -110,16 +111,10 @@ public class Builder { Buildpacks buildpacks = getBuildpacks(request, imageFetcher, builderMetadata, buildpackLayersMetadata); EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(), builderMetadata, request.getCreator(), request.getEnv(), buildpacks); - this.docker.image().load(ephemeralBuilder.getArchive(), UpdateListener.none()); - try { - executeLifecycle(request, ephemeralBuilder); - tagImage(request.getName(), request.getTags()); - if (request.isPublish()) { - pushImages(request.getName(), request.getTags()); - } - } - finally { - this.docker.image().remove(ephemeralBuilder.getName(), true); + executeLifecycle(request, ephemeralBuilder); + tagImage(request.getName(), request.getTags()); + if (request.isPublish()) { + pushImages(request.getName(), request.getTags()); } } @@ -157,13 +152,25 @@ public class Builder { } private void executeLifecycle(BuildRequest request, EphemeralBuilder builder) throws IOException { - ResolvedDockerHost dockerHost = null; - if (this.dockerConfiguration != null && this.dockerConfiguration.isBindHostToBuilder()) { - dockerHost = ResolvedDockerHost.from(this.dockerConfiguration.getHost()); + try (Lifecycle lifecycle = new Lifecycle(this.log, this.docker, getDockerHost(), request, builder)) { + executeLifecycle(builder, lifecycle); } - try (Lifecycle lifecycle = new Lifecycle(this.log, this.docker, dockerHost, request, builder)) { + } + + private void executeLifecycle(EphemeralBuilder builder, Lifecycle lifecycle) throws IOException { + ImageArchive archive = builder.getArchive(lifecycle.getApplicationDirectory()); + this.docker.image().load(archive, UpdateListener.none()); + try { lifecycle.execute(); } + finally { + this.docker.image().remove(builder.getName(), true); + } + } + + private ResolvedDockerHost getDockerHost() { + boolean bindHostToBuilder = this.dockerConfiguration != null && this.dockerConfiguration.isBindHostToBuilder(); + return (bindHostToBuilder) ? ResolvedDockerHost.from(this.dockerConfiguration.getHost()) : null; } private void tagImage(ImageReference sourceReference, List tags) throws IOException { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilder.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilder.java index 3ac1ee7a785..9471a4b6985 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilder.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -21,11 +21,14 @@ import java.util.Map; import org.springframework.boot.buildpack.platform.docker.type.Image; import org.springframework.boot.buildpack.platform.docker.type.ImageArchive; +import org.springframework.boot.buildpack.platform.docker.type.ImageArchive.Update; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.docker.type.Layer; import org.springframework.boot.buildpack.platform.io.Content; +import org.springframework.boot.buildpack.platform.io.IOConsumer; import org.springframework.boot.buildpack.platform.io.Owner; import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; /** * A short-lived builder that is created for each {@link Lifecycle} run. @@ -37,13 +40,17 @@ class EphemeralBuilder { static final String BUILDER_FOR_LABEL_NAME = "org.springframework.boot.builderFor"; + private ImageReference name; + private final BuildOwner buildOwner; + private final Creator creator; + private final BuilderMetadata builderMetadata; - private final ImageArchive archive; + private final Image builderImage; - private final Creator creator; + private final IOConsumer archiveUpdate; /** * Create a new {@link EphemeralBuilder} instance. @@ -54,26 +61,25 @@ class EphemeralBuilder { * @param creator the builder creator * @param env the builder env * @param buildpacks an optional set of buildpacks to apply - * @throws IOException on IO error */ EphemeralBuilder(BuildOwner buildOwner, Image builderImage, ImageReference targetImage, - BuilderMetadata builderMetadata, Creator creator, Map env, Buildpacks buildpacks) - throws IOException { - ImageReference name = ImageReference.random("pack.local/builder/").inTaggedForm(); + BuilderMetadata builderMetadata, Creator creator, Map env, Buildpacks buildpacks) { + this.name = ImageReference.random("pack.local/builder/").inTaggedForm(); this.buildOwner = buildOwner; this.creator = creator; this.builderMetadata = builderMetadata.copy(this::updateMetadata); - this.archive = ImageArchive.from(builderImage, (update) -> { + this.builderImage = builderImage; + this.archiveUpdate = (update) -> { update.withUpdatedConfig(this.builderMetadata::attachTo); update.withUpdatedConfig((config) -> config.withLabel(BUILDER_FOR_LABEL_NAME, targetImage.toString())); - update.withTag(name); + update.withTag(this.name); if (!CollectionUtils.isEmpty(env)) { update.withNewLayer(getEnvLayer(env)); } if (buildpacks != null) { buildpacks.apply(update::withNewLayer); } - }); + }; } private void updateMetadata(BuilderMetadata.Update update) { @@ -95,7 +101,7 @@ class EphemeralBuilder { * @return the ephemeral builder name */ ImageReference getName() { - return this.archive.getTag(); + return this.name; } /** @@ -116,15 +122,26 @@ class EphemeralBuilder { /** * Return the contents of ephemeral builder for passing to Docker. + * @param applicationDirectory the application directory * @return the ephemeral builder archive + * @throws IOException on IO error */ - ImageArchive getArchive() { - return this.archive; + ImageArchive getArchive(String applicationDirectory) throws IOException { + return ImageArchive.from(this.builderImage, (update) -> { + this.archiveUpdate.accept(update); + if (StringUtils.hasLength(applicationDirectory)) { + update.withNewLayer(applicationDirectoryLayer(applicationDirectory)); + } + }); + } + + private Layer applicationDirectoryLayer(String applicationDirectory) throws IOException { + return Layer.of((layout) -> layout.directory(applicationDirectory, this.buildOwner)); } @Override public String toString() { - return this.archive.getTag().toString(); + return this.name.toString(); } } 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 3a68b6d99d6..072e8c25c97 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -116,6 +116,10 @@ class Lifecycle implements Closeable { this.securityOptions = getSecurityOptions(request); } + String getApplicationDirectory() { + return this.applicationDirectory; + } + private Cache getBuildCache(BuildRequest request) { if (request.getBuildCache() != null) { return request.getBuildCache(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilderTests.java index ef877fd3ee0..75ab37ee21b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilderTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/EphemeralBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 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. @@ -88,7 +88,7 @@ class EphemeralBuilderTests extends AbstractJsonTests { } @Test - void getNameHasRandomName() throws Exception { + void getNameHasRandomName() { EphemeralBuilder b1 = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, this.creator, this.env, this.buildpacks); EphemeralBuilder b2 = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, @@ -101,7 +101,7 @@ class EphemeralBuilderTests extends AbstractJsonTests { void getArchiveHasCreatedByConfig() throws Exception { EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, this.creator, this.env, this.buildpacks); - ImageConfig config = builder.getArchive().getImageConfig(); + ImageConfig config = builder.getArchive(null).getImageConfig(); BuilderMetadata ephemeralMetadata = BuilderMetadata.fromImageConfig(config); assertThat(ephemeralMetadata.getCreatedBy().getName()).isEqualTo("Spring Boot"); assertThat(ephemeralMetadata.getCreatedBy().getVersion()).isEqualTo("dev"); @@ -111,7 +111,7 @@ class EphemeralBuilderTests extends AbstractJsonTests { void getArchiveHasTag() throws Exception { EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, this.creator, this.env, this.buildpacks); - ImageReference tag = builder.getArchive().getTag(); + ImageReference tag = builder.getArchive(null).getTag(); assertThat(tag.toString()).startsWith("pack.local/builder/").endsWith(":latest"); } @@ -119,7 +119,7 @@ class EphemeralBuilderTests extends AbstractJsonTests { void getArchiveHasFixedCreatedDate() throws Exception { EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, this.creator, this.env, this.buildpacks); - Instant createInstant = builder.getArchive().getCreateDate(); + Instant createInstant = builder.getArchive(null).getCreateDate(); OffsetDateTime createDateTime = OffsetDateTime.ofInstant(createInstant, ZoneId.of("UTC")); assertThat(createDateTime.getYear()).isEqualTo(1980); assertThat(createDateTime.getMonthValue()).isOne(); @@ -133,7 +133,7 @@ class EphemeralBuilderTests extends AbstractJsonTests { void getArchiveContainsEnvLayer() throws Exception { EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, this.creator, this.env, this.buildpacks); - File directory = unpack(getLayer(builder.getArchive(), EXISTING_IMAGE_LAYER_COUNT), "env"); + File directory = unpack(getLayer(builder.getArchive(null), EXISTING_IMAGE_LAYER_COUNT), "env"); assertThat(new File(directory, "platform/env/spring")).usingCharset(StandardCharsets.UTF_8).hasContent("boot"); assertThat(new File(directory, "platform/env/empty")).usingCharset(StandardCharsets.UTF_8).hasContent(""); } @@ -142,7 +142,7 @@ class EphemeralBuilderTests extends AbstractJsonTests { void getArchiveHasBuilderForLabel() throws Exception { EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, this.creator, this.env, this.buildpacks); - ImageConfig config = builder.getArchive().getImageConfig(); + ImageConfig config = builder.getArchive(null).getImageConfig(); assertThat(config.getLabels()) .contains(entry(EphemeralBuilder.BUILDER_FOR_LABEL_NAME, this.targetImage.toString())); } @@ -162,13 +162,21 @@ class EphemeralBuilderTests extends AbstractJsonTests { "/cnb/buildpacks/example_buildpack2/0.0.2/buildpack.toml"); assertBuildpackLayerContent(builder, EXISTING_IMAGE_LAYER_COUNT + 2, "/cnb/buildpacks/example_buildpack3/0.0.3/buildpack.toml"); - File orderDirectory = unpack(getLayer(builder.getArchive(), EXISTING_IMAGE_LAYER_COUNT + 3), "order"); + File orderDirectory = unpack(getLayer(builder.getArchive(null), EXISTING_IMAGE_LAYER_COUNT + 3), "order"); assertThat(new File(orderDirectory, "cnb/order.toml")).usingCharset(StandardCharsets.UTF_8) .hasContent(content("order.toml")); } + @Test + void getArchiveHasApplicationDirectoryLayer() throws Exception { + EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.targetImage, this.metadata, + this.creator, this.env, this.buildpacks); + File directory = unpack(getLayer(builder.getArchive("/myapp"), EXISTING_IMAGE_LAYER_COUNT + 1), "appdir"); + assertThat(new File(directory, "myapp")).isDirectory(); + } + private void assertBuildpackLayerContent(EphemeralBuilder builder, int index, String s) throws Exception { - File buildpackDirectory = unpack(getLayer(builder.getArchive(), index), "buildpack"); + File buildpackDirectory = unpack(getLayer(builder.getArchive(null), index), "buildpack"); assertThat(new File(buildpackDirectory, s)).usingCharset(StandardCharsets.UTF_8).hasContent("[test]"); }