Skip to content

Commit 8b75975

Browse files
prafgupSwapnanil-Gupta
authored andcommitted
Fix multi-platform soci standalone convert
Signed-off-by: Praful Gupta <prafulgupta6@gmail.com>
1 parent c039069 commit 8b75975

2 files changed

Lines changed: 155 additions & 67 deletions

File tree

cmd/soci/commands/internal/standalone.go

Lines changed: 62 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,11 @@ func LoadImage(ctx context.Context, inputPath string, tmpDir string) (*Standalon
7070
if err != nil {
7171
return nil, fmt.Errorf("failed to read index.json from %s: %w", inputPath, err)
7272
}
73-
rootDesc, err := parseRootDescriptor(indexData)
73+
rootDesc, err := resolveLayoutRoot(tmpDir, indexData)
7474
if err != nil {
7575
return nil, err
7676
}
7777

78-
// If the root descriptor is a manifest list (e.g. from nerdctl save),
79-
// resolve it to available platform manifests. This handles partial exports
80-
// where the manifest list references all platforms but only a subset of
81-
// platform blobs were exported.
82-
if images.IsIndexType(rootDesc.MediaType) {
83-
rootDesc, err = resolveManifestList(tmpDir, rootDesc)
84-
if err != nil {
85-
return nil, err
86-
}
87-
}
88-
8978
orasStore, err := oci.New(tmpDir)
9079
if err != nil {
9180
return nil, fmt.Errorf("failed to create writable OCI store: %w", err)
@@ -139,73 +128,81 @@ func SaveImageToDir(srcDir string, desc ocispec.Descriptor, outputPath string) e
139128
return os.WriteFile(filepath.Join(outputPath, "index.json"), indexData, 0644)
140129
}
141130

142-
// parseRootDescriptor unmarshals OCI index JSON and returns the manifest descriptor.
143-
func parseRootDescriptor(indexData []byte) (ocispec.Descriptor, error) {
144-
var index ocispec.Index
145-
if err := json.Unmarshal(indexData, &index); err != nil {
146-
return ocispec.Descriptor{}, fmt.Errorf("failed to unmarshal index.json: %w", err)
147-
}
148-
if len(index.Manifests) == 0 {
131+
// resolveLayoutRoot returns a root descriptor for the OCI image layout. The result
132+
// is either a single image manifest descriptor or a manifest list descriptor.
133+
//
134+
// It accepts index.json shapes produced by common tools: a single image manifest,
135+
// a single descriptor pointing at a nested manifest list (e.g. nerdctl save), or
136+
// a flat list of per-platform manifests (e.g. go-containerregistry layout.Write).
137+
// If some children are missing their blobs, they are filtered out and a new
138+
// manifest list blob is written into layoutDir.
139+
func resolveLayoutRoot(layoutDir string, indexData []byte) (ocispec.Descriptor, error) {
140+
var top ocispec.Index
141+
if err := json.Unmarshal(indexData, &top); err != nil {
142+
return ocispec.Descriptor{}, fmt.Errorf("unmarshal index.json: %w", err)
143+
}
144+
if len(top.Manifests) == 0 {
149145
return ocispec.Descriptor{}, errors.New("index.json contains no manifests")
150146
}
151-
return index.Manifests[0], nil
152-
}
153-
154-
// resolveManifestList reads a manifest list blob and resolves it based on which
155-
// platform blobs are actually present in the layout. If all platforms are available,
156-
// it returns the original manifest list. If only one is available, it returns that
157-
// platform manifest directly. If multiple (but not all) are available, it writes a
158-
// filtered manifest list containing only the available platforms. This handles tools
159-
// like `nerdctl save` that export a manifest list referencing all platforms even when
160-
// only a subset was pulled.
161-
func resolveManifestList(layoutDir string, listDesc ocispec.Descriptor) (ocispec.Descriptor, error) {
162-
blobPath := filepath.Join(layoutDir, "blobs", listDesc.Digest.Algorithm().String(), listDesc.Digest.Encoded())
163-
listData, err := os.ReadFile(blobPath)
164-
if err != nil {
165-
return ocispec.Descriptor{}, fmt.Errorf("failed to read manifest list blob: %w", err)
166-
}
167-
168-
var manifestList ocispec.Index
169-
if err := json.Unmarshal(listData, &manifestList); err != nil {
170-
return ocispec.Descriptor{}, fmt.Errorf("failed to unmarshal manifest list: %w", err)
147+
// Single non-index entry: a plain single-platform image.
148+
if len(top.Manifests) == 1 && !images.IsIndexType(top.Manifests[0].MediaType) {
149+
return top.Manifests[0], nil
171150
}
172151

173-
// Find which platform manifests have their blobs present
174-
var available []ocispec.Descriptor
175-
for _, desc := range manifestList.Manifests {
176-
p := filepath.Join(layoutDir, "blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded())
177-
if _, err := os.Stat(p); err == nil {
178-
available = append(available, desc)
152+
// Locate the manifest list to walk. Either index.json points at a nested list
153+
// blob, or index.json is itself the list.
154+
var (
155+
listBytes = indexData
156+
mediaType = top.MediaType
157+
)
158+
if len(top.Manifests) == 1 {
159+
mediaType = top.Manifests[0].MediaType
160+
b, err := os.ReadFile(blobPath(layoutDir, top.Manifests[0].Digest))
161+
if err != nil {
162+
return ocispec.Descriptor{}, fmt.Errorf("read manifest list: %w", err)
179163
}
164+
listBytes = b
180165
}
181-
if len(available) == 0 {
182-
return ocispec.Descriptor{}, errors.New("manifest list contains no manifests with available blobs")
166+
if mediaType == "" {
167+
mediaType = ocispec.MediaTypeImageIndex
183168
}
184169

185-
// If all platforms are available, keep the original manifest list
186-
if len(available) == len(manifestList.Manifests) {
187-
return listDesc, nil
170+
var list ocispec.Index
171+
if err := json.Unmarshal(listBytes, &list); err != nil {
172+
return ocispec.Descriptor{}, fmt.Errorf("unmarshal manifest list: %w", err)
188173
}
189174

190-
// If only one platform is available, return it directly as a single manifest
191-
if len(available) == 1 {
175+
available := make([]ocispec.Descriptor, 0, len(list.Manifests))
176+
for _, d := range list.Manifests {
177+
if _, err := os.Stat(blobPath(layoutDir, d.Digest)); err == nil {
178+
available = append(available, d)
179+
}
180+
}
181+
switch {
182+
case len(available) == 0:
183+
return ocispec.Descriptor{}, errors.New("manifest list contains no entries with available blobs")
184+
case len(available) == 1 && images.IsManifestType(available[0].MediaType):
192185
return available[0], nil
186+
case len(top.Manifests) == 1 && len(available) == len(list.Manifests):
187+
return top.Manifests[0], nil
193188
}
194189

195-
// Multiple (but not all) platforms available: write a filtered manifest list
196-
manifestList.Manifests = available
197-
filteredData, err := json.Marshal(manifestList)
190+
list.MediaType = mediaType
191+
list.Manifests = available
192+
data, err := json.Marshal(list)
198193
if err != nil {
199-
return ocispec.Descriptor{}, fmt.Errorf("failed to marshal filtered manifest list: %w", err)
194+
return ocispec.Descriptor{}, fmt.Errorf("marshal manifest list: %w", err)
200195
}
201-
filteredDigest := digest.FromBytes(filteredData)
202-
filteredPath := filepath.Join(layoutDir, "blobs", filteredDigest.Algorithm().String(), filteredDigest.Encoded())
203-
if err := os.WriteFile(filteredPath, filteredData, 0644); err != nil {
204-
return ocispec.Descriptor{}, fmt.Errorf("failed to write filtered manifest list: %w", err)
196+
dgst := digest.FromBytes(data)
197+
if err := os.MkdirAll(filepath.Dir(blobPath(layoutDir, dgst)), 0755); err != nil {
198+
return ocispec.Descriptor{}, err
205199
}
206-
return ocispec.Descriptor{
207-
MediaType: listDesc.MediaType,
208-
Digest: filteredDigest,
209-
Size: int64(len(filteredData)),
210-
}, nil
200+
if err := os.WriteFile(blobPath(layoutDir, dgst), data, 0644); err != nil {
201+
return ocispec.Descriptor{}, err
202+
}
203+
return ocispec.Descriptor{MediaType: mediaType, Digest: dgst, Size: int64(len(data))}, nil
204+
}
205+
206+
func blobPath(layoutDir string, dgst digest.Digest) string {
207+
return filepath.Join(layoutDir, "blobs", dgst.Algorithm().String(), dgst.Encoded())
211208
}

integration/convert_standalone_test.go

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@
1717
package integration
1818

1919
import (
20+
"encoding/json"
2021
"path/filepath"
2122
"strings"
2223
"testing"
2324

25+
"github.com/awslabs/soci-snapshotter/soci"
2426
"github.com/awslabs/soci-snapshotter/util/dockershell"
2527
"github.com/awslabs/soci-snapshotter/util/testutil"
2628
"github.com/containerd/platforms"
29+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
2730
)
2831

2932
func TestStandaloneConvertBasic(t *testing.T) {
@@ -255,10 +258,98 @@ func TestStandaloneConvertIdempotent(t *testing.T) {
255258
}
256259
}
257260

258-
func exportToOCIDir(sh *dockershell.Shell, imageRef, outputDir string) {
261+
func exportToOCIDir(sh *dockershell.Shell, imageRef, outputDir string, saveArgs ...string) {
259262
exportTar := outputDir + ".export.tar"
260-
sh.X("nerdctl", "save", "-o", exportTar, imageRef)
263+
sh.X(append(append([]string{"nerdctl", "save"}, saveArgs...), "-o", exportTar, imageRef)...)
261264
sh.X("mkdir", "-p", outputDir)
262265
sh.X("tar", "-xf", exportTar, "-C", outputDir)
263266
sh.X("rm", "-f", exportTar)
264267
}
268+
269+
func TestStandaloneConvertAllPlatforms(t *testing.T) {
270+
regConfig := newRegistryConfig()
271+
sh, done := newShellWithRegistry(t, regConfig)
272+
defer done()
273+
274+
rebootContainerd(t, sh, getContainerdConfigToml(t, false), getSnapshotterConfigToml(t))
275+
276+
// Pull and save the multi-arch image directly from its public registry.
277+
srcRef := dockerhub(nginxImage).ref
278+
279+
baseDir, err := testutil.TempDir(sh)
280+
if err != nil {
281+
t.Fatalf("failed to create temp dir: %v", err)
282+
}
283+
defer sh.X("rm", "-rf", baseDir)
284+
285+
inputDir := filepath.Join(baseDir, "input")
286+
outputTar := filepath.Join(baseDir, "output.tar")
287+
288+
sh.X("nerdctl", "pull", "-q", "--all-platforms", srcRef)
289+
exportToOCIDir(sh, srcRef, inputDir, "--all-platforms")
290+
291+
srcDigest := getImageDigest(sh, srcRef)
292+
var srcPlatforms []string
293+
for _, m := range readIndex(t, sh, srcDigest).Manifests {
294+
if m.Platform != nil {
295+
srcPlatforms = append(srcPlatforms, platforms.Format(*m.Platform))
296+
}
297+
}
298+
if len(srcPlatforms) < 2 {
299+
t.Skipf("expected multi-arch input, got %d platforms", len(srcPlatforms))
300+
}
301+
302+
stopContainerd(t, sh)
303+
304+
sh.X("soci", "convert",
305+
"--standalone",
306+
"--all-platforms",
307+
"--min-layer-size=0",
308+
inputDir,
309+
outputTar,
310+
)
311+
312+
rebootContainerd(t, sh, getContainerdConfigToml(t, false), getSnapshotterConfigToml(t))
313+
314+
dstRef := srcRef + "-standalone-allplatforms"
315+
sh.X("ctr", "images", "import", "--no-unpack", "--index-name", dstRef, outputTar)
316+
317+
dstDigest := getImageDigest(sh, dstRef)
318+
validateConversion(t, sh, srcDigest, dstDigest)
319+
320+
imgs, sociIdx := map[string]bool{}, map[string]bool{}
321+
for _, m := range readIndex(t, sh, dstDigest).Manifests {
322+
if m.Platform == nil {
323+
continue
324+
}
325+
key := platforms.Format(*m.Platform)
326+
switch m.ArtifactType {
327+
case soci.SociIndexArtifactTypeV2:
328+
sociIdx[key] = true
329+
case "":
330+
imgs[key] = true
331+
}
332+
}
333+
for _, p := range srcPlatforms {
334+
if !imgs[p] {
335+
t.Errorf("converted output missing image manifest for %s", p)
336+
}
337+
if !sociIdx[p] {
338+
t.Errorf("converted output missing SOCI index for %s", p)
339+
}
340+
}
341+
}
342+
343+
// readIndex fetches an OCI image index from the content store, unwrapping a
344+
// single-manifest wrapper (as produced by `ctr images import --index-name`).
345+
func readIndex(t *testing.T, sh *dockershell.Shell, indexDigest string) ocispec.Index {
346+
t.Helper()
347+
var idx ocispec.Index
348+
if err := json.Unmarshal(sh.O("ctr", "content", "get", indexDigest), &idx); err != nil {
349+
t.Fatalf("parse index %s: %v", indexDigest, err)
350+
}
351+
if len(idx.Manifests) == 1 && idx.Manifests[0].MediaType == ocispec.MediaTypeImageIndex {
352+
return readIndex(t, sh, idx.Manifests[0].Digest.String())
353+
}
354+
return idx
355+
}

0 commit comments

Comments
 (0)