Skip to content

Commit ecdc1a5

Browse files
feat: support multi-arch image indexes (upstream GoogleContainerTools#503)
Adapts upstream PR GoogleContainerTools#503 ("Add support for multi-arch image indexes") to the fork. Upstream placed the index-selection logic inline in test.go, but the fork's GoogleContainerTools#554 since centralized OCI-layout loading into pkgutil.ImageFromOCILayout (shared by the Docker daemon path and the Tar driver). The platform-based selection is added there instead, so both drivers gain multi-arch support. ImageFromOCILayout now takes a platform string; callers pass opts.Platform / args.Platform. Two fork-authored changes beyond the upstream diff: - Nil-platform guard: v1.Descriptor.Platform is a nil-able *v1.Platform (OCI permits an index descriptor with no platform), and Satisfies has a value receiver, so the upstream desc.Platform.Satisfies(...) call panics with a nil dereference on such descriptors. findImageInIndex now skips any descriptor whose Platform is nil. Covered by a unit test that panics without the guard and passes with it. - parsePlatform replaced with go-containerregistry's v1.ParsePlatform, which accepts variants (e.g. linux/arm/v7) that the hand-rolled parser rejected. Also fixes the bundled shell test: numeric comparison uses -lt instead of the string operator <, and the multi-arch temp dir no longer clobbers the $tmp used by the following tar-driver test. Co-authored-by: Joel Sing <jsing@canva.com> Upstream-PR: GoogleContainerTools#503
1 parent edeb98c commit ecdc1a5

7 files changed

Lines changed: 235 additions & 11 deletions

File tree

cmd/container-structure-test/app/cmd/test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func run(out io.Writer) error {
113113
if opts.Driver == drivers.Tar {
114114
args.OCILayout = opts.ImageFromLayout
115115
} else {
116-
img, desc, err := pkgutil.ImageFromOCILayout(opts.ImageFromLayout)
116+
img, desc, err := pkgutil.ImageFromOCILayout(opts.ImageFromLayout, opts.Platform)
117117
if err != nil {
118118
logrus.Fatalf("loading OCI layout %s: %v", opts.ImageFromLayout, err)
119119
}

internal/pkgutil/image_utils.go

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package pkgutil
1818

1919
import (
2020
"archive/tar"
21+
stderrors "errors"
2122
"fmt"
2223
"io"
2324
"io/ioutil"
@@ -204,10 +205,20 @@ func ImageFromV1(img v1.Image, source string) (Image, error) {
204205
}, nil
205206
}
206207

208+
// maxSubIndexes caps how deep findImageInIndex recurses into nested image
209+
// indexes, guarding against malicious or malformed layouts.
210+
const maxSubIndexes = 5
211+
212+
// errNoImageFound reports that no descriptor in an index satisfied the
213+
// requested platform. It is used as a sentinel so recursion can distinguish
214+
// "this subtree had no match" from a real error.
215+
var errNoImageFound = stderrors.New("no compatible image found")
216+
207217
// ImageFromOCILayout loads a single image from an OCI image layout directory.
208-
// It returns the image and its descriptor (for access to annotations).
209-
// The layout must contain exactly one non-index manifest entry.
210-
func ImageFromOCILayout(path string) (v1.Image, v1.Descriptor, error) {
218+
// It returns the image and its descriptor (for access to annotations). The
219+
// layout must contain exactly one entry. When that entry is a multi-arch image
220+
// index, the descriptor matching platform (e.g. "linux/amd64") is selected.
221+
func ImageFromOCILayout(path, platform string) (v1.Image, v1.Descriptor, error) {
211222
l, err := layout.ImageIndexFromPath(path)
212223
if err != nil {
213224
return nil, v1.Descriptor{}, errors.Wrapf(err, "loading %s as OCI layout", path)
@@ -222,7 +233,19 @@ func ImageFromOCILayout(path string) (v1.Image, v1.Descriptor, error) {
222233

223234
desc := m.Manifests[0]
224235
if desc.MediaType.IsIndex() {
225-
return nil, v1.Descriptor{}, errors.New("multi-arch images are not supported yet")
236+
requirements, err := v1.ParsePlatform(platform)
237+
if err != nil {
238+
return nil, v1.Descriptor{}, errors.Wrapf(err, "parsing platform %q", platform)
239+
}
240+
childIndex, err := l.ImageIndex(desc.Digest)
241+
if err != nil {
242+
return nil, v1.Descriptor{}, errors.Wrapf(err, "reading image index from %s", path)
243+
}
244+
img, err := findImageInIndex(childIndex, requirements, 0)
245+
if err != nil {
246+
return nil, v1.Descriptor{}, errors.Wrapf(err, "selecting image for platform %q from %s", platform, path)
247+
}
248+
return img, desc, nil
226249
}
227250

228251
img, err := l.Image(desc.Digest)
@@ -233,6 +256,45 @@ func ImageFromOCILayout(path string) (v1.Image, v1.Descriptor, error) {
233256
return img, desc, nil
234257
}
235258

259+
// findImageInIndex walks an image index, recursing into nested sub-indexes up
260+
// to maxSubIndexes deep, and returns the first image whose platform satisfies
261+
// requirements. Descriptors without a platform field (which OCI permits) are
262+
// skipped rather than dereferenced, since Descriptor.Platform is a nil-able
263+
// pointer and Satisfies has a value receiver.
264+
func findImageInIndex(index v1.ImageIndex, requirements *v1.Platform, depth int) (v1.Image, error) {
265+
if depth > maxSubIndexes {
266+
return nil, errors.Errorf("too many sub-indexes (>%d)", maxSubIndexes)
267+
}
268+
269+
manifest, err := index.IndexManifest()
270+
if err != nil {
271+
return nil, errors.Wrap(err, "reading index manifest")
272+
}
273+
274+
for _, desc := range manifest.Manifests {
275+
if desc.MediaType.IsImage() {
276+
if desc.Platform == nil {
277+
continue
278+
}
279+
if desc.Platform.Satisfies(*requirements) {
280+
return index.Image(desc.Digest)
281+
}
282+
}
283+
if desc.MediaType.IsIndex() {
284+
childIndex, err := index.ImageIndex(desc.Digest)
285+
if err != nil {
286+
return nil, err
287+
}
288+
img, err := findImageInIndex(childIndex, requirements, depth+1)
289+
if !stderrors.Is(err, errNoImageFound) {
290+
return img, err
291+
}
292+
}
293+
}
294+
295+
return nil, errNoImageFound
296+
}
297+
236298
func getExtractPathForName(name string, cacheDir string) (string, error) {
237299
path := cacheDir
238300
var err error

internal/pkgutil/image_utils_test.go

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,95 @@ func writeOCILayout(t *testing.T, imgs ...v1.Image) string {
6464
return dir
6565
}
6666

67+
// platformedImage pairs an image with the platform descriptor it should be
68+
// indexed under. A nil platform produces an index entry whose Descriptor.Platform
69+
// is nil, which OCI permits and which must not panic Satisfies.
70+
type platformedImage struct {
71+
img v1.Image
72+
platform *v1.Platform
73+
}
74+
75+
// writeMultiArchOCILayout writes a single multi-arch image index (built from the
76+
// given images and platforms) as the sole entry of a fresh OCI layout directory.
77+
// This mirrors the shape produced by `crane pull --format=oci` of a manifest list.
78+
func writeMultiArchOCILayout(t *testing.T, entries ...platformedImage) string {
79+
t.Helper()
80+
81+
idx := v1.ImageIndex(empty.Index)
82+
for _, e := range entries {
83+
add := mutate.IndexAddendum{Add: e.img}
84+
if e.platform != nil {
85+
add.Descriptor = v1.Descriptor{Platform: e.platform}
86+
}
87+
idx = mutate.AppendManifests(idx, add)
88+
}
89+
90+
dir := t.TempDir()
91+
l, err := layout.Write(dir, empty.Index)
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
if err := l.AppendIndex(idx); err != nil {
96+
t.Fatal(err)
97+
}
98+
return dir
99+
}
100+
101+
func TestImageFromOCILayoutSelectsMatchingPlatform(t *testing.T) {
102+
amd64 := testImageWithFile(t, "etc/amd64.txt", "amd64-data")
103+
arm64 := testImageWithFile(t, "etc/arm64.txt", "arm64-data")
104+
dir := writeMultiArchOCILayout(t,
105+
platformedImage{img: amd64, platform: &v1.Platform{OS: "linux", Architecture: "amd64"}},
106+
platformedImage{img: arm64, platform: &v1.Platform{OS: "linux", Architecture: "arm64"}},
107+
)
108+
109+
gotImg, _, err := ImageFromOCILayout(dir, "linux/arm64")
110+
if err != nil {
111+
t.Fatal(err)
112+
}
113+
114+
wantDigest, err := arm64.Digest()
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
gotDigest, err := gotImg.Digest()
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
if gotDigest != wantDigest {
123+
t.Fatalf("selected image digest = %v, want arm64 image %v", gotDigest, wantDigest)
124+
}
125+
}
126+
127+
// TestImageFromOCILayoutSkipsNilPlatform guards the nil-deref fix: an index
128+
// descriptor without a platform field must be skipped rather than passed to the
129+
// value-receiver Satisfies, which would panic on a nil *v1.Platform.
130+
func TestImageFromOCILayoutSkipsNilPlatform(t *testing.T) {
131+
noPlatform := testImageWithFile(t, "etc/nop.txt", "no-platform-data")
132+
amd64 := testImageWithFile(t, "etc/amd64.txt", "amd64-data")
133+
dir := writeMultiArchOCILayout(t,
134+
platformedImage{img: noPlatform, platform: nil},
135+
platformedImage{img: amd64, platform: &v1.Platform{OS: "linux", Architecture: "amd64"}},
136+
)
137+
138+
gotImg, _, err := ImageFromOCILayout(dir, "linux/amd64")
139+
if err != nil {
140+
t.Fatal(err)
141+
}
142+
143+
wantDigest, err := amd64.Digest()
144+
if err != nil {
145+
t.Fatal(err)
146+
}
147+
gotDigest, err := gotImg.Digest()
148+
if err != nil {
149+
t.Fatal(err)
150+
}
151+
if gotDigest != wantDigest {
152+
t.Fatalf("selected image digest = %v, want amd64 image %v", gotDigest, wantDigest)
153+
}
154+
}
155+
67156
func TestImageFromV1ExtractsFilesystem(t *testing.T) {
68157
img := testImageWithFile(t, "etc/known.txt", "image-data")
69158

@@ -100,7 +189,7 @@ func TestImageFromOCILayoutLoadsSingleImage(t *testing.T) {
100189
img := testImageWithFile(t, "etc/known.txt", "image-data")
101190
dir := writeOCILayout(t, img)
102191

103-
gotImg, _, err := ImageFromOCILayout(dir)
192+
gotImg, _, err := ImageFromOCILayout(dir, "linux/amd64")
104193
if err != nil {
105194
t.Fatal(err)
106195
}
@@ -121,7 +210,7 @@ func TestImageFromOCILayoutLoadsSingleImage(t *testing.T) {
121210
func TestImageFromOCILayoutMissingPath(t *testing.T) {
122211
missing := filepath.Join(t.TempDir(), "does-not-exist")
123212

124-
_, _, err := ImageFromOCILayout(missing)
213+
_, _, err := ImageFromOCILayout(missing, "linux/amd64")
125214
if err == nil {
126215
t.Fatal("ImageFromOCILayout accepted a missing layout path")
127216
}
@@ -133,7 +222,7 @@ func TestImageFromOCILayoutRejectsMultipleEntries(t *testing.T) {
133222
testImageWithFile(t, "etc/b.txt", "b-data"),
134223
)
135224

136-
_, _, err := ImageFromOCILayout(dir)
225+
_, _, err := ImageFromOCILayout(dir, "linux/amd64")
137226
if err == nil {
138227
t.Fatal("ImageFromOCILayout accepted a layout with multiple entries")
139228
}

pkg/drivers/tar_driver.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type TarDriver struct {
3535

3636
func NewTarDriver(args DriverConfig) (Driver, error) {
3737
if args.OCILayout != "" {
38-
image, err := imageFromOCILayout(args.OCILayout)
38+
image, err := imageFromOCILayout(args.OCILayout, args.Platform)
3939
if err != nil {
4040
return nil, errors.Wrap(err, "processing OCI layout")
4141
}
@@ -77,8 +77,8 @@ func NewTarDriver(args DriverConfig) (Driver, error) {
7777
}, nil
7878
}
7979

80-
func imageFromOCILayout(path string) (pkgutil.Image, error) {
81-
img, _, err := pkgutil.ImageFromOCILayout(path)
80+
func imageFromOCILayout(path, platform string) (pkgutil.Image, error) {
81+
img, _, err := pkgutil.ImageFromOCILayout(path, platform)
8282
if err != nil {
8383
return pkgutil.Image{}, err
8484
}

tests/amd64/fluent_bit_test.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
schemaVersion: '2.0.0' # Make sure to test the latest schema version
2+
commandTests:
3+
- name: 'fluent-bit'
4+
command: '/fluent-bit/bin/fluent-bit'
5+
args: ['--version']
6+
expectedOutput: ['Fluent Bit v4.0.4']
7+
fileContentTests:
8+
- name: 'Passwd file'
9+
expectedContents: ['root:x:0:0:root:/root:/sbin/nologin']
10+
path: '/etc/passwd'
11+
fileExistenceTests:
12+
- name: 'Root'
13+
path: '/'
14+
shouldExist: true
15+
uid: 0
16+
gid: 0
17+
- name: 'libc'
18+
path: '/lib/x86_64-linux-gnu/libc.so.6'
19+
shouldExist: true

tests/arm64/fluent_bit_test.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
schemaVersion: '2.0.0' # Make sure to test the latest schema version
2+
commandTests:
3+
- name: 'fluent-bit'
4+
command: '/fluent-bit/bin/fluent-bit'
5+
args: ['--version']
6+
expectedOutput: ['Fluent Bit v4.0.4']
7+
fileContentTests:
8+
- name: 'Passwd file'
9+
expectedContents: ['root:x:0:0:root:/root:/sbin/nologin']
10+
path: '/etc/passwd'
11+
fileExistenceTests:
12+
- name: 'Root'
13+
path: '/'
14+
shouldExist: true
15+
uid: 0
16+
gid: 0
17+
- name: 'libc'
18+
path: '/lib/aarch64-linux-gnu/libc.so.6'
19+
shouldExist: true

tests/structure_test_tests.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,41 @@ else
268268
echo "PASS: oci success test case"
269269
fi
270270

271+
HEADER "OCI multi-arch image index test case"
272+
273+
if [[ "$go_architecture" == "amd64" || "$go_architecture" == "arm64" ]]; then
274+
test_index="cr.fluentbit.io/fluent/fluent-bit:4.0.4"
275+
276+
manifest=$(docker manifest inspect "$test_index")
277+
if [[ $(echo "$manifest" | jq '.mediaType') != '"application/vnd.docker.distribution.manifest.list.v2+json"' ]]; then
278+
echo "FAIL: multi-arch image index test case - $test_index is not an image index"
279+
echo "$manifest" | jq '.mediaType'
280+
failures=$((failures +1))
281+
fi
282+
image_count=$(echo "$manifest" | jq '.manifests | length')
283+
if [[ $image_count -lt 2 ]]; then
284+
echo "FAIL: multi-arch image index test case - $test_index only has $image_count image(s)"
285+
failures=$((failures +1))
286+
fi
287+
288+
tmp_index="$(mktemp -d)"
289+
crane pull "$test_index" --format=oci "$tmp_index"
290+
291+
res=$(./out/container-structure-test test --image-from-oci-layout="$tmp_index" --default-image-tag="test.local/$test_index" --config "${test_config_dir}/fluent_bit_test.yaml" 2>&1)
292+
code=$?
293+
if ! [[ ("$res" =~ "PASS" && "$code" == "0") ]];
294+
then
295+
echo "FAIL: oci image index success test case"
296+
echo "$res"
297+
echo "$code"
298+
failures=$((failures +1))
299+
else
300+
echo "PASS: oci image index test case"
301+
fi
302+
else
303+
echo "SKIP: oci image index test case not supported on $go_architecture"
304+
fi
305+
271306
HEADER "OCI layout with tar driver test case"
272307

273308
res=$(./out/container-structure-test test --driver tar --image-from-oci-layout="$tmp" --config "${test_config_dir}/ubuntu_22_04_tar_test.yaml" 2>&1)

0 commit comments

Comments
 (0)