Skip to content

Commit 3a5dec7

Browse files
committed
Fix multi-platform soci standalone convert
1 parent f1fcd05 commit 3a5dec7

2 files changed

Lines changed: 158 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 descriptor for the OCI image layout's root manifest.
132+
// It accepts index.json shapes produced by common tools: a single image manifest,
133+
// a single descriptor pointing at a nested manifest list (e.g. nerdctl save), or
134+
// a flat list of per-platform manifests (e.g. go-containerregistry layout.Write).
135+
// Children whose blobs are missing are filtered out.
136+
func resolveLayoutRoot(layoutDir string, indexData []byte) (ocispec.Descriptor, error) {
137+
var top ocispec.Index
138+
if err := json.Unmarshal(indexData, &top); err != nil {
139+
return ocispec.Descriptor{}, fmt.Errorf("unmarshal index.json: %w", err)
140+
}
141+
if len(top.Manifests) == 0 {
149142
return ocispec.Descriptor{}, errors.New("index.json contains no manifests")
150143
}
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)
144+
// Single non-index entry: a plain single-platform image.
145+
if len(top.Manifests) == 1 && !images.IsIndexType(top.Manifests[0].MediaType) {
146+
return top.Manifests[0], nil
171147
}
172148

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)
149+
// Locate the manifest list to walk. Either index.json points at a nested list
150+
// blob, or index.json is itself the list.
151+
var (
152+
listDesc ocispec.Descriptor
153+
listBytes = indexData
154+
mediaType = top.MediaType
155+
)
156+
if len(top.Manifests) == 1 {
157+
listDesc = top.Manifests[0]
158+
mediaType = listDesc.MediaType
159+
b, err := os.ReadFile(blobPath(layoutDir, listDesc.Digest))
160+
if err != nil {
161+
return ocispec.Descriptor{}, fmt.Errorf("read manifest list: %w", err)
179162
}
163+
listBytes = b
180164
}
181-
if len(available) == 0 {
182-
return ocispec.Descriptor{}, errors.New("manifest list contains no manifests with available blobs")
165+
if mediaType == "" {
166+
mediaType = ocispec.MediaTypeImageIndex
183167
}
184168

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

190-
// If only one platform is available, return it directly as a single manifest
191-
if len(available) == 1 {
174+
available := make([]ocispec.Descriptor, 0, len(list.Manifests))
175+
for _, d := range list.Manifests {
176+
if _, err := os.Stat(blobPath(layoutDir, d.Digest)); err == nil {
177+
available = append(available, d)
178+
}
179+
}
180+
switch {
181+
case len(available) == 0:
182+
return ocispec.Descriptor{}, errors.New("manifest list contains no entries with available blobs")
183+
case len(available) == 1 && images.IsManifestType(available[0].MediaType):
192184
return available[0], nil
185+
case listDesc.Digest != "" && len(available) == len(list.Manifests):
186+
// Nested list is fully available; keep its original digest.
187+
return listDesc, 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: 96 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,101 @@ 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+
// Regression test: standalone conversion used to drop every platform after the
270+
// first whenever the input OCI layout contained multiple platforms.
271+
func TestStandaloneConvertAllPlatforms(t *testing.T) {
272+
regConfig := newRegistryConfig()
273+
sh, done := newShellWithRegistry(t, regConfig)
274+
defer done()
275+
276+
rebootContainerd(t, sh, getContainerdConfigToml(t, false), getSnapshotterConfigToml(t))
277+
278+
// The test registry mirror only holds a single platform, so pull and save
279+
// the multi-arch image directly from its public registry.
280+
srcRef := dockerhub(nginxImage).ref
281+
282+
baseDir, err := testutil.TempDir(sh)
283+
if err != nil {
284+
t.Fatalf("failed to create temp dir: %v", err)
285+
}
286+
defer sh.X("rm", "-rf", baseDir)
287+
288+
inputDir := filepath.Join(baseDir, "input")
289+
outputTar := filepath.Join(baseDir, "output.tar")
290+
291+
sh.X("nerdctl", "pull", "-q", "--all-platforms", srcRef)
292+
exportToOCIDir(sh, srcRef, inputDir, "--all-platforms")
293+
294+
srcDigest := getImageDigest(sh, srcRef)
295+
var srcPlatforms []string
296+
for _, m := range readIndex(t, sh, srcDigest).Manifests {
297+
if m.Platform != nil {
298+
srcPlatforms = append(srcPlatforms, platforms.Format(*m.Platform))
299+
}
300+
}
301+
if len(srcPlatforms) < 2 {
302+
t.Skipf("expected multi-arch input, got %d platforms", len(srcPlatforms))
303+
}
304+
305+
stopContainerd(t, sh)
306+
307+
sh.X("soci", "convert",
308+
"--standalone",
309+
"--all-platforms",
310+
"--min-layer-size=0",
311+
inputDir,
312+
outputTar,
313+
)
314+
315+
rebootContainerd(t, sh, getContainerdConfigToml(t, false), getSnapshotterConfigToml(t))
316+
317+
dstRef := srcRef + "-standalone-allplatforms"
318+
sh.X("ctr", "images", "import", "--no-unpack", "--index-name", dstRef, outputTar)
319+
320+
dstDigest := getImageDigest(sh, dstRef)
321+
validateConversion(t, sh, srcDigest, dstDigest)
322+
323+
imgs, sociIdx := map[string]bool{}, map[string]bool{}
324+
for _, m := range readIndex(t, sh, dstDigest).Manifests {
325+
if m.Platform == nil {
326+
continue
327+
}
328+
key := platforms.Format(*m.Platform)
329+
switch m.ArtifactType {
330+
case soci.SociIndexArtifactTypeV2:
331+
sociIdx[key] = true
332+
case "":
333+
imgs[key] = true
334+
}
335+
}
336+
for _, p := range srcPlatforms {
337+
if !imgs[p] {
338+
t.Errorf("converted output missing image manifest for %s", p)
339+
}
340+
if !sociIdx[p] {
341+
t.Errorf("converted output missing SOCI index for %s", p)
342+
}
343+
}
344+
}
345+
346+
// readIndex fetches an OCI image index from the content store, unwrapping a
347+
// single-manifest wrapper (as produced by `ctr images import --index-name`).
348+
func readIndex(t *testing.T, sh *dockershell.Shell, indexDigest string) ocispec.Index {
349+
t.Helper()
350+
var idx ocispec.Index
351+
if err := json.Unmarshal(sh.O("ctr", "content", "get", indexDigest), &idx); err != nil {
352+
t.Fatalf("parse index %s: %v", indexDigest, err)
353+
}
354+
if len(idx.Manifests) == 1 && idx.Manifests[0].MediaType == ocispec.MediaTypeImageIndex {
355+
return readIndex(t, sh, idx.Manifests[0].Digest.String())
356+
}
357+
return idx
358+
}

0 commit comments

Comments
 (0)