diff --git a/daemon/containerd/image_save_test.go b/daemon/containerd/image_save_test.go new file mode 100644 index 0000000000..13a9c86565 --- /dev/null +++ b/daemon/containerd/image_save_test.go @@ -0,0 +1,103 @@ +package containerd + +import ( + "context" + "io" + "path/filepath" + "testing" + + "github.com/containerd/containerd/namespaces" + "github.com/containerd/platforms" + "github.com/docker/docker/errdefs" + "github.com/docker/docker/internal/testutils/specialimage" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestImageMultiplatformSaveShallowWithNative(t *testing.T) { + ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name()) + + contentDir := t.TempDir() + store := &blobsDirContentStore{blobs: filepath.Join(contentDir, "blobs/sha256")} + + native := platforms.Platform{ + OS: "linux", + Architecture: "amd64", + } + + arm64 := platforms.Platform{ + OS: "linux", + Architecture: "arm64", + } + + imgSvc := fakeImageService(t, ctx, store) + // Mock the native platform. + imgSvc.defaultPlatformOverride = platforms.Only(native) + + idx, _, err := specialimage.PartialMultiPlatform(contentDir, "partial-with-native:latest", specialimage.PartialOpts{ + Stored: []ocispec.Platform{native}, + Missing: []ocispec.Platform{arm64}, + }) + assert.NilError(t, err) + + img, err := imgSvc.images.Create(ctx, imagesFromIndex(idx)[0]) + assert.NilError(t, err) + + t.Run("export without specific platform", func(t *testing.T) { + err = imgSvc.ExportImage(ctx, []string{img.Name}, nil, io.Discard) + assert.NilError(t, err) + }) + t.Run("export native", func(t *testing.T) { + err = imgSvc.ExportImage(ctx, []string{img.Name}, &native, io.Discard) + assert.NilError(t, err) + }) + t.Run("export missing", func(t *testing.T) { + err = imgSvc.ExportImage(ctx, []string{img.Name}, &arm64, io.Discard) + assert.Check(t, is.ErrorType(err, errdefs.IsNotFound)) + }) +} + +func TestImageMultiplatformSaveShallowWithoutNative(t *testing.T) { + ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name()) + + contentDir := t.TempDir() + store := &blobsDirContentStore{blobs: filepath.Join(contentDir, "blobs/sha256")} + + native := platforms.Platform{ + OS: "linux", + Architecture: "amd64", + } + + arm64 := platforms.Platform{ + OS: "linux", + Architecture: "arm64", + } + + imgSvc := fakeImageService(t, ctx, store) + // Mock the native platform. + imgSvc.defaultPlatformOverride = platforms.Only(native) + + idx, _, err := specialimage.PartialMultiPlatform(contentDir, "partial-without-native:latest", specialimage.PartialOpts{ + Stored: []ocispec.Platform{arm64}, + Missing: []ocispec.Platform{native}, + }) + assert.NilError(t, err) + + img, err := imgSvc.images.Create(ctx, imagesFromIndex(idx)[0]) + assert.NilError(t, err) + + t.Run("export without specific platform", func(t *testing.T) { + t.Skip("TODO(vvoland): https://github.com/docker/cli/issues/5476") + err = imgSvc.ExportImage(ctx, []string{img.Name}, nil, io.Discard) + assert.NilError(t, err) + }) + t.Run("export native", func(t *testing.T) { + err = imgSvc.ExportImage(ctx, []string{img.Name}, &native, io.Discard) + assert.Check(t, is.ErrorType(err, errdefs.IsNotFound)) + }) + t.Run("export arm64", func(t *testing.T) { + err = imgSvc.ExportImage(ctx, []string{img.Name}, &arm64, io.Discard) + assert.NilError(t, err) + }) +} diff --git a/internal/testutils/specialimage/partial.go b/internal/testutils/specialimage/partial.go new file mode 100644 index 0000000000..5ea0469aed --- /dev/null +++ b/internal/testutils/specialimage/partial.go @@ -0,0 +1,56 @@ +package specialimage + +import ( + "github.com/containerd/platforms" + "github.com/distribution/reference" + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +type PartialOpts struct { + Stored []ocispec.Platform + Missing []ocispec.Platform +} + +// PartialMultiPlatform creates an index with all platforms in storedPlatforms +// and missingPlatforms. However, only the blobs of the storedPlatforms are +// created and stored, while the missingPlatforms are only referenced in the +// index. +func PartialMultiPlatform(dir string, imageRef string, opts PartialOpts) (*ocispec.Index, []ocispec.Descriptor, error) { + ref, err := reference.ParseNormalizedNamed(imageRef) + if err != nil { + return nil, nil, err + } + + var descs []ocispec.Descriptor + + for _, platform := range opts.Stored { + ps := platforms.Format(platform) + manifestDesc, err := oneLayerPlatformManifest(dir, platform, FileInLayer{Path: "bash", Content: []byte("layer-" + ps)}) + if err != nil { + return nil, nil, err + } + descs = append(descs, manifestDesc) + } + + for _, platform := range opts.Missing { + platformStr := platforms.FormatAll(platform) + dgst := digest.FromBytes([]byte(platformStr)) + + platform := platform + descs = append(descs, ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Size: 128, + Platform: &platform, + Digest: dgst, + }) + } + + idx, err := multiPlatformImage(dir, ref, ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: descs, + }) + return idx, descs, err +}