Skip to content

Commit 7730eee

Browse files
Use image manifest when exporting layers
A tar archive of a Docker image contains a `mainfest.json` file that lists the path to each embedded tar file containing the contents of a layer in the image. This manifest file should be used to identify the layer files instead of relying on file naming conventions and assumptions on the directory structure that are not consistent between container engine implementations. Fixes gh-34324
1 parent 27ba20f commit 7730eee

File tree

11 files changed

+329
-49
lines changed

11 files changed

+329
-49
lines changed

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2022 the original author or authors.
2+
* Copyright 2012-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
package org.springframework.boot.buildpack.platform.build;
1818

1919
import java.io.IOException;
20+
import java.nio.file.Path;
2021
import java.util.List;
2122
import java.util.function.Consumer;
2223

@@ -32,7 +33,6 @@
3233
import org.springframework.boot.buildpack.platform.docker.type.Image;
3334
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
3435
import org.springframework.boot.buildpack.platform.io.IOBiConsumer;
35-
import org.springframework.boot.buildpack.platform.io.TarArchive;
3636
import org.springframework.util.Assert;
3737
import org.springframework.util.StringUtils;
3838

@@ -273,9 +273,8 @@ public Image fetchImage(ImageReference reference, ImageType imageType) throws IO
273273
}
274274

275275
@Override
276-
public void exportImageLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
277-
throws IOException {
278-
Builder.this.docker.image().exportLayers(reference, exports);
276+
public void exportImageLayers(ImageReference reference, IOBiConsumer<String, Path> exports) throws IOException {
277+
Builder.this.docker.image().exportLayerFiles(reference, exports);
279278
}
280279

281280
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildpackResolverContext.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2022 the original author or authors.
2+
* Copyright 2012-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,12 +17,12 @@
1717
package org.springframework.boot.buildpack.platform.build;
1818

1919
import java.io.IOException;
20+
import java.nio.file.Path;
2021
import java.util.List;
2122

2223
import org.springframework.boot.buildpack.platform.docker.type.Image;
2324
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
2425
import org.springframework.boot.buildpack.platform.io.IOBiConsumer;
25-
import org.springframework.boot.buildpack.platform.io.TarArchive;
2626

2727
/**
2828
* Context passed to a {@link BuildpackResolver}.
@@ -52,6 +52,6 @@ interface BuildpackResolverContext {
5252
* during the callback)
5353
* @throws IOException on IO error
5454
*/
55-
void exportImageLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports) throws IOException;
55+
void exportImageLayers(ImageReference reference, IOBiConsumer<String, Path> exports) throws IOException;
5656

5757
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ImageBuildpack.java

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.boot.buildpack.platform.build;
1818

1919
import java.io.IOException;
20+
import java.io.InputStream;
2021
import java.io.OutputStream;
2122
import java.nio.file.Files;
2223
import java.nio.file.Path;
@@ -35,7 +36,6 @@
3536
import org.springframework.boot.buildpack.platform.docker.type.Layer;
3637
import org.springframework.boot.buildpack.platform.docker.type.LayerId;
3738
import org.springframework.boot.buildpack.platform.io.IOConsumer;
38-
import org.springframework.boot.buildpack.platform.io.TarArchive;
3939
import org.springframework.util.StreamUtils;
4040

4141
/**
@@ -115,23 +115,16 @@ private static class ExportedLayers {
115115

116116
ExportedLayers(BuildpackResolverContext context, ImageReference imageReference) throws IOException {
117117
List<Path> layerFiles = new ArrayList<>();
118-
context.exportImageLayers(imageReference, (name, archive) -> layerFiles.add(copyToTemp(name, archive)));
118+
context.exportImageLayers(imageReference, (name, path) -> layerFiles.add(copyToTemp(path)));
119119
this.layerFiles = Collections.unmodifiableList(layerFiles);
120120
}
121121

122-
private Path copyToTemp(String name, TarArchive archive) throws IOException {
123-
String[] parts = name.split("/");
124-
Path path = Files.createTempFile("create-builder-scratch-", parts[0]);
125-
try (OutputStream out = Files.newOutputStream(path)) {
126-
archive.writeTo(out);
127-
}
128-
return path;
129-
}
130-
131-
void apply(IOConsumer<Layer> layers) throws IOException {
132-
for (Path path : this.layerFiles) {
133-
layers.accept(Layer.fromTarArchive((out) -> copyLayerTar(path, out)));
122+
private Path copyToTemp(Path path) throws IOException {
123+
Path outputPath = Files.createTempFile("create-builder-scratch-", null);
124+
try (OutputStream out = Files.newOutputStream(outputPath)) {
125+
copyLayerTar(path, out);
134126
}
127+
return outputPath;
135128
}
136129

137130
private void copyLayerTar(Path path, OutputStream out) throws IOException {
@@ -147,7 +140,16 @@ private void copyLayerTar(Path path, OutputStream out) throws IOException {
147140
}
148141
tarOut.finish();
149142
}
150-
Files.delete(path);
143+
}
144+
145+
void apply(IOConsumer<Layer> layers) throws IOException {
146+
for (Path path : this.layerFiles) {
147+
layers.accept(Layer.fromTarArchive((out) -> {
148+
InputStream in = Files.newInputStream(path);
149+
StreamUtils.copy(in, out);
150+
}));
151+
Files.delete(path);
152+
}
151153
}
152154

153155
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,24 @@
1616

1717
package org.springframework.boot.buildpack.platform.docker;
1818

19+
import java.io.BufferedReader;
20+
import java.io.ByteArrayInputStream;
1921
import java.io.IOException;
22+
import java.io.InputStream;
23+
import java.io.InputStreamReader;
24+
import java.io.OutputStream;
2025
import java.net.URI;
2126
import java.net.URISyntaxException;
27+
import java.nio.charset.StandardCharsets;
28+
import java.nio.file.Files;
29+
import java.nio.file.Path;
2230
import java.util.Arrays;
2331
import java.util.Collection;
2432
import java.util.Collections;
33+
import java.util.HashMap;
2534
import java.util.List;
35+
import java.util.Map;
36+
import java.util.stream.Collectors;
2637

2738
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
2839
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
@@ -37,6 +48,7 @@
3748
import org.springframework.boot.buildpack.platform.docker.type.ContainerStatus;
3849
import org.springframework.boot.buildpack.platform.docker.type.Image;
3950
import org.springframework.boot.buildpack.platform.docker.type.ImageArchive;
51+
import org.springframework.boot.buildpack.platform.docker.type.ImageArchiveManifest;
4052
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
4153
import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
4254
import org.springframework.boot.buildpack.platform.io.IOBiConsumer;
@@ -250,28 +262,57 @@ public void load(ImageArchive archive, UpdateListener<LoadImageUpdateEvent> list
250262
}
251263

252264
/**
253-
* Export the layers of an image.
265+
* Export the layers of an image as {@link TarArchive}s.
254266
* @param reference the reference to export
255267
* @param exports a consumer to receive the layers (contents can only be accessed
256268
* during the callback)
257269
* @throws IOException on IO error
258270
*/
259271
public void exportLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
260272
throws IOException {
273+
exportLayerFiles(reference, (name, path) -> {
274+
try (InputStream in = Files.newInputStream(path)) {
275+
TarArchive archive = (out) -> StreamUtils.copy(in, out);
276+
exports.accept(name, archive);
277+
}
278+
});
279+
}
280+
281+
/**
282+
* Export the layers of an image as paths to layer tar files.
283+
* @param reference the reference to export
284+
* @param exports a consumer to receive the layer tar file paths (file can only be
285+
* accessed during the callback)
286+
* @throws IOException on IO error
287+
*/
288+
public void exportLayerFiles(ImageReference reference, IOBiConsumer<String, Path> exports) throws IOException {
261289
Assert.notNull(reference, "Reference must not be null");
262290
Assert.notNull(exports, "Exports must not be null");
263291
URI saveUri = buildUrl("/images/" + reference + "/get");
264292
Response response = http().get(saveUri);
293+
ImageArchiveManifest manifest = null;
294+
Map<String, Path> layerFiles = new HashMap<>();
265295
try (TarArchiveInputStream tar = new TarArchiveInputStream(response.getContent())) {
266296
TarArchiveEntry entry = tar.getNextTarEntry();
267297
while (entry != null) {
268-
if (entry.getName().endsWith("/layer.tar")) {
269-
TarArchive archive = (out) -> StreamUtils.copy(tar, out);
270-
exports.accept(entry.getName(), archive);
298+
if (entry.getName().equals("manifest.json")) {
299+
manifest = readManifest(tar);
300+
}
301+
if (entry.getName().endsWith(".tar")) {
302+
layerFiles.put(entry.getName(), copyToTemp(tar));
271303
}
272304
entry = tar.getNextTarEntry();
273305
}
274306
}
307+
Assert.notNull(manifest, "Manifest not found in image " + reference);
308+
for (Map.Entry<String, Path> entry : layerFiles.entrySet()) {
309+
String name = entry.getKey();
310+
Path path = entry.getValue();
311+
if (manifestContainsLayerEntry(manifest, name)) {
312+
exports.accept(name, path);
313+
}
314+
Files.delete(path);
315+
}
275316
}
276317

277318
/**
@@ -308,6 +349,24 @@ public void tag(ImageReference sourceReference, ImageReference targetReference)
308349
http().post(uri).close();
309350
}
310351

352+
private ImageArchiveManifest readManifest(TarArchiveInputStream tar) throws IOException {
353+
String manifestContent = new BufferedReader(new InputStreamReader(tar, StandardCharsets.UTF_8)).lines()
354+
.collect(Collectors.joining());
355+
return ImageArchiveManifest.of(new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8)));
356+
}
357+
358+
private Path copyToTemp(TarArchiveInputStream in) throws IOException {
359+
Path path = Files.createTempFile("create-builder-scratch-", null);
360+
try (OutputStream out = Files.newOutputStream(path)) {
361+
StreamUtils.copy(in, out);
362+
}
363+
return path;
364+
}
365+
366+
private boolean manifestContainsLayerEntry(ImageArchiveManifest manifest, String layerId) {
367+
return manifest.getEntries().stream().anyMatch((content) -> content.getLayers().contains(layerId));
368+
}
369+
311370
}
312371

313372
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2012-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.buildpack.platform.docker.type;
18+
19+
import java.io.IOException;
20+
import java.io.InputStream;
21+
import java.lang.invoke.MethodHandles;
22+
import java.util.ArrayList;
23+
import java.util.Collections;
24+
import java.util.List;
25+
26+
import com.fasterxml.jackson.databind.JsonNode;
27+
28+
import org.springframework.boot.buildpack.platform.json.MappedObject;
29+
30+
/**
31+
* Image archive manifest information.
32+
*
33+
* @author Scott Frederick
34+
* @since 2.7.9
35+
*/
36+
public class ImageArchiveManifest extends MappedObject {
37+
38+
private final List<ManifestEntry> entries = new ArrayList<>();
39+
40+
protected ImageArchiveManifest(JsonNode node) {
41+
super(node, MethodHandles.lookup());
42+
getNode().elements().forEachRemaining((element) -> this.entries.add(ManifestEntry.of(element)));
43+
}
44+
45+
/**
46+
* Return the entries contained in the manifest.
47+
* @return the manifest entries
48+
*/
49+
public List<ManifestEntry> getEntries() {
50+
return this.entries;
51+
}
52+
53+
/**
54+
* Create an {@link ImageArchiveManifest} from the provided JSON input stream.
55+
* @param content the JSON input stream
56+
* @return a new {@link ImageArchiveManifest} instance
57+
* @throws IOException on IO error
58+
*/
59+
public static ImageArchiveManifest of(InputStream content) throws IOException {
60+
return of(content, ImageArchiveManifest::new);
61+
}
62+
63+
public static class ManifestEntry extends MappedObject {
64+
65+
private final List<String> layers;
66+
67+
protected ManifestEntry(JsonNode node) {
68+
super(node, MethodHandles.lookup());
69+
this.layers = extractLayers();
70+
}
71+
72+
/**
73+
* Return the collection of layer IDs from a section of the manifest.
74+
* @return a collection of layer IDs
75+
*/
76+
public List<String> getLayers() {
77+
return this.layers;
78+
}
79+
80+
static ManifestEntry of(JsonNode node) {
81+
return new ManifestEntry(node);
82+
}
83+
84+
@SuppressWarnings("unchecked")
85+
private List<String> extractLayers() {
86+
List<String> layers = valueAt("/Layers", List.class);
87+
if (layers == null) {
88+
return Collections.emptyList();
89+
}
90+
return layers;
91+
}
92+
93+
}
94+
95+
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/ImageBuildpackTests.java

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818

1919
import java.io.ByteArrayInputStream;
2020
import java.io.ByteArrayOutputStream;
21+
import java.io.File;
22+
import java.io.FileOutputStream;
2123
import java.io.IOException;
24+
import java.nio.file.Path;
2225
import java.util.ArrayList;
2326
import java.util.List;
2427
import java.util.Random;
@@ -33,7 +36,6 @@
3336
import org.springframework.boot.buildpack.platform.docker.type.Image;
3437
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
3538
import org.springframework.boot.buildpack.platform.io.IOBiConsumer;
36-
import org.springframework.boot.buildpack.platform.io.TarArchive;
3739
import org.springframework.boot.buildpack.platform.json.AbstractJsonTests;
3840

3941
import static org.assertj.core.api.Assertions.assertThat;
@@ -173,20 +175,20 @@ void resolveWhenUnqualifiedReferenceWithInvalidImageReferenceReturnsNull() {
173175

174176
private Object withMockLayers(InvocationOnMock invocation) {
175177
try {
176-
IOBiConsumer<String, TarArchive> consumer = invocation.getArgument(1);
177-
TarArchive archive = (out) -> {
178-
try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) {
179-
tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
180-
writeTarEntry(tarOut, "/cnb/");
181-
writeTarEntry(tarOut, "/cnb/buildpacks/");
182-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/");
183-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/");
184-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/buildpack.toml");
185-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/" + this.longFilePath);
186-
tarOut.finish();
187-
}
188-
};
189-
consumer.accept("test", archive);
178+
IOBiConsumer<String, Path> consumer = invocation.getArgument(1);
179+
File tarFile = File.createTempFile("create-builder-test-", null);
180+
FileOutputStream out = new FileOutputStream(tarFile);
181+
try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) {
182+
tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
183+
writeTarEntry(tarOut, "/cnb/");
184+
writeTarEntry(tarOut, "/cnb/buildpacks/");
185+
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/");
186+
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/");
187+
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/buildpack.toml");
188+
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/" + this.longFilePath);
189+
tarOut.finish();
190+
}
191+
consumer.accept("test", tarFile.toPath());
190192
}
191193
catch (IOException ex) {
192194
fail("Error writing mock layers", ex);

0 commit comments

Comments
 (0)