Skip to content

Commit 58a5cb9

Browse files
cpuguy83jedevc
authored andcommitted
ResolveImageConfig: Only fetch best matching config
Before this change, all platforms that loosely match the provided platform will be fetched even though we only care about 1 of them. As an example when linux/amd64 is requested it will also fetch linux/386 because it is a compatible architecture. This means extra round trips to the registry, potentially even for content that doesn't exist in the remote. This is especially a problem when resolve mode is prefer-local because we'll have the index locally but most likely only one manifest. In this case we'll end up reaching out to the registry to fetch the other manifests unncessarily. With this change instead of fetching all matching platforms it chooses only the best matching platform. Signed-off-by: Brian Goff <[email protected]> (cherry picked from commit 575cb10) Signed-off-by: Justin Chadwell <[email protected]>
1 parent 3a2904a commit 58a5cb9

File tree

2 files changed

+208
-0
lines changed

2 files changed

+208
-0
lines changed

util/imageutil/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ func Config(ctx context.Context, str string, resolver remotes.Resolver, cache Co
156156
}
157157

158158
children := childrenConfigHandler(cache, platform)
159+
children = images.LimitManifests(children, platform, 1)
159160

160161
dslHandler, err := docker.AppendDistributionSourceLabel(cache, ref.String())
161162
if err != nil {

util/imageutil/config_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package imageutil
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"io"
8+
"math/rand"
9+
"testing"
10+
11+
"github.com/containerd/containerd/content"
12+
"github.com/containerd/containerd/errdefs"
13+
"github.com/containerd/containerd/platforms"
14+
"github.com/containerd/containerd/remotes"
15+
digest "github.com/opencontainers/go-digest"
16+
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestConfigMultiplatform(t *testing.T) {
21+
ctx := context.Background()
22+
23+
cc := &testCache{}
24+
25+
pAmd64 := platforms.MustParse("linux/amd64")
26+
cfgDescAmd64 := cc.Add(t, ocispecs.Image{Platform: pAmd64}, ocispecs.MediaTypeImageConfig, nil)
27+
mfstAmd64 := ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest, Config: cfgDescAmd64}
28+
29+
// Add linux/amd64 to the cache
30+
descAmd64 := cc.Add(t, mfstAmd64, mfstAmd64.MediaType, &pAmd64)
31+
32+
// Make a compatible manifest but do not add to the cache
33+
p386 := platforms.MustParse("linux/386")
34+
cfgDesc386 := cc.Add(t, ocispecs.Image{Platform: p386}, ocispecs.MediaTypeImageConfig, nil)
35+
mfst386 := ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest, Config: cfgDesc386}
36+
_, desc386 := makeDesc(t, mfst386, mfst386.MediaType, &p386)
37+
38+
// And add an extra non-compataible platform
39+
pArm64 := platforms.MustParse("linux/arm64")
40+
cfgDescArm64 := cc.Add(t, ocispecs.Image{Platform: pArm64}, ocispecs.MediaTypeImageConfig, nil)
41+
mfstArm64 := ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest, Config: cfgDescArm64}
42+
_, descArm64 := makeDesc(t, mfst386, mfstArm64.MediaType, &pArm64)
43+
44+
idx := ocispecs.Index{
45+
MediaType: ocispecs.MediaTypeImageIndex,
46+
Manifests: []ocispecs.Descriptor{desc386, descAmd64, descArm64},
47+
}
48+
49+
check := func(t *testing.T) {
50+
t.Helper()
51+
52+
idxDesc := cc.Add(t, idx, idx.MediaType, nil)
53+
r := &testResolver{cc: cc, resolve: func(ctx context.Context, ref string) (string, ocispecs.Descriptor, error) {
54+
return ref, idxDesc, nil
55+
}}
56+
57+
// Now we should be able to get the amd64 config without fetching anything from the remote
58+
// If it tries to fetch from the remote this will error out.
59+
const ref = "example.com/test:latest"
60+
_, _, dt, err := Config(ctx, ref, r, cc, nil, &pAmd64, nil)
61+
require.NoError(t, err)
62+
63+
var cfg ocispecs.Image
64+
require.NoError(t, json.Unmarshal(dt, &cfg))
65+
// Validate this is the amd64 config
66+
require.True(t, platforms.OnlyStrict(cfg.Platform).Match(pAmd64))
67+
68+
// Make sure it doesn't select a non-matching platform
69+
pArmv7 := platforms.MustParse("linux/arm/v7")
70+
_, _, _, err = Config(ctx, ref, r, cc, nil, &pArmv7, nil)
71+
require.ErrorIs(t, err, errdefs.ErrNotFound)
72+
}
73+
74+
check(t)
75+
76+
// Shuffle the manifests around and make sure it still works
77+
rand.Shuffle(len(idx.Manifests), func(i, j int) {
78+
idx.Manifests[i], idx.Manifests[j] = idx.Manifests[j], idx.Manifests[i]
79+
})
80+
check(t)
81+
}
82+
83+
type testCache struct {
84+
content.Manager
85+
content map[digest.Digest]content.ReaderAt
86+
}
87+
88+
func (testCache) Info(ctx context.Context, dgst digest.Digest) (content.Info, error) {
89+
return content.Info{}, nil
90+
}
91+
92+
func (testCache) Update(context.Context, content.Info, ...string) (content.Info, error) {
93+
return content.Info{}, nil
94+
}
95+
96+
func (*testCache) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) {
97+
// This needs to be implemented because the content helpers will open a writer to use as a lock
98+
return nopWriter{}, nil
99+
}
100+
101+
type nopWriter struct{}
102+
103+
func (nopWriter) Write(p []byte) (int, error) {
104+
return len(p), nil
105+
}
106+
107+
func (nopWriter) Close() error {
108+
return nil
109+
}
110+
111+
func (nopWriter) Commit(ctx context.Context, size int64, expected digest.Digest, opts ...content.Opt) error {
112+
return nil
113+
}
114+
115+
func (nopWriter) Status() (content.Status, error) {
116+
return content.Status{}, nil
117+
}
118+
119+
func (nopWriter) Truncate(size int64) error {
120+
return nil
121+
}
122+
123+
func (nopWriter) Digest() digest.Digest {
124+
panic("not implemented")
125+
}
126+
127+
func makeDesc(t *testing.T, item any, mt string, p *ocispecs.Platform) ([]byte, ocispecs.Descriptor) {
128+
t.Helper()
129+
130+
dt, err := json.Marshal(item)
131+
require.NoError(t, err)
132+
133+
return dt, ocispecs.Descriptor{
134+
MediaType: mt,
135+
Digest: digest.FromBytes(dt),
136+
Size: int64(len(dt)),
137+
Platform: p,
138+
}
139+
}
140+
141+
func (c *testCache) Add(t *testing.T, item any, mt string, p *ocispecs.Platform) ocispecs.Descriptor {
142+
if c.content == nil {
143+
c.content = make(map[digest.Digest]content.ReaderAt)
144+
}
145+
146+
dt, desc := makeDesc(t, item, mt, p)
147+
c.content[desc.Digest] = &sectionNopCloser{io.NewSectionReader(bytes.NewReader(dt), 0, int64(len(dt)))}
148+
return desc
149+
}
150+
151+
type sectionNopCloser struct {
152+
*io.SectionReader
153+
}
154+
155+
func (*sectionNopCloser) Close() error {
156+
return nil
157+
}
158+
159+
func (c *testCache) ReaderAt(ctx context.Context, desc ocispecs.Descriptor) (content.ReaderAt, error) {
160+
ra, ok := c.content[desc.Digest]
161+
if !ok {
162+
return nil, errdefs.ErrNotFound
163+
}
164+
return ra, nil
165+
}
166+
167+
type testResolver struct {
168+
resolve func(ctx context.Context, ref string) (string, ocispecs.Descriptor, error)
169+
cc ContentCache
170+
}
171+
172+
type fetcherFunc func(context.Context, ocispecs.Descriptor) (io.ReadCloser, error)
173+
174+
func (f fetcherFunc) Fetch(ctx context.Context, desc ocispecs.Descriptor) (io.ReadCloser, error) {
175+
return f(ctx, desc)
176+
}
177+
178+
func (r *testResolver) Fetcher(ctx context.Context, ref string) (remotes.Fetcher, error) {
179+
return fetcherFunc(func(ctx context.Context, desc ocispecs.Descriptor) (io.ReadCloser, error) {
180+
ra, err := r.cc.ReaderAt(ctx, desc)
181+
if err != nil {
182+
return nil, err
183+
}
184+
return &raReader{ReaderAt: ra, Closer: ra}, nil
185+
}), nil
186+
}
187+
188+
type raReader struct {
189+
io.ReaderAt
190+
io.Closer
191+
192+
pos int
193+
}
194+
195+
func (r *raReader) Read(p []byte) (int, error) {
196+
n, err := r.ReaderAt.ReadAt(p, int64(r.pos))
197+
r.pos += n
198+
return n, err
199+
}
200+
201+
func (r *testResolver) Resolve(ctx context.Context, ref string) (string, ocispecs.Descriptor, error) {
202+
return r.resolve(ctx, ref)
203+
}
204+
205+
func (r *testResolver) Pusher(context.Context, string) (remotes.Pusher, error) {
206+
panic("not implemented")
207+
}

0 commit comments

Comments
 (0)