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
This commit is contained in:
Phillip Webb
2025-04-22 15:48:37 -07:00
parent 2ffea0f344
commit f6dae1397d
4 changed files with 75 additions and 39 deletions

View File

@@ -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<ImageReference> tags) throws IOException {

View File

@@ -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<Update> 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<String, String> env, Buildpacks buildpacks)
throws IOException {
ImageReference name = ImageReference.random("pack.local/builder/").inTaggedForm();
BuilderMetadata builderMetadata, Creator creator, Map<String, String> 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();
}
}

View File

@@ -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();

View File

@@ -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]");
}