diff --git a/daemon/containerd/image_exporter.go b/daemon/containerd/image_exporter.go index a27fd2c6fe..1fd877e047 100644 --- a/daemon/containerd/image_exporter.go +++ b/daemon/containerd/image_exporter.go @@ -256,8 +256,6 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf opts := []containerd.ImportOpt{ containerd.WithImportPlatform(pm), - containerd.WithSkipMissing(), - // Create an additional image with dangling name for imported images... containerd.WithDigestRef(danglingImageName), // ... but only if they don't have a name or it's invalid. @@ -270,13 +268,51 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf }), } + if platform == nil { + // Allow variants to be missing if no specific platform is requested. + opts = append(opts, containerd.WithSkipMissing()) + } + imgs, err := i.client.Import(ctx, decompressed, opts...) if err != nil { + if platform != nil { + p := platforms.FormatAll(*platform) + log.G(ctx).WithFields(log.Fields{"error": err, "platform": p}).Debug("failed to import image to containerd") + + // Note: ErrEmptyWalk will not be returned in most cases as + // index.json will contain a descriptor of the actual OCI index or + // Docker manifest list, so the walk is never empty. + // Even in case of a single-platform image, the manifest descriptor + // doesn't have a platform set, so it won't be filtered out by the + // FilterPlatform containerd handler. + if errors.Is(err, containerdimages.ErrEmptyWalk) { + return errdefs.NotFound(errors.Wrapf(err, "requested platform (%s) not found", p)) + } + if cerrdefs.IsNotFound(err) { + return errdefs.NotFound(errors.Wrapf(err, "requested platform (%s) found, but some content is missing", p)) + } + } log.G(ctx).WithError(err).Debug("failed to import image to containerd") return errdefs.System(err) } + if platform != nil { + // Verify that the requested platform is available for the loaded images. + // While the ideal behavior here would be to verify whether the input + // archive actually supplied them, we're not able to determine that + // as the imported index is not returned by the import operation. + if err := i.verifyImagesProvidePlatform(ctx, imgs, *platform, pm); err != nil { + return err + } + } + progress := streamformatter.NewStdoutWriter(outStream) + // Unpack only an image of the host platform + unpackPm := i.hostPlatformMatcher() + // If a load of specific platform is requested, unpack it + if platform != nil { + unpackPm = pm + } for _, img := range imgs { name := img.Name @@ -310,8 +346,7 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf return nil } - // Only unpack the image if it matches the host platform - if !i.hostPlatformMatcher().Match(imgPlat) { + if !unpackPm.Match(imgPlat) { return nil } @@ -342,3 +377,57 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf return nil } + +// verifyImagesProvidePlatform checks if the requested platform is loaded. +// If the requested platform is not loaded, it returns an error. +func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []containerdimages.Image, platform ocispec.Platform, pm platforms.Matcher) error { + if len(imgs) == 0 { + return errdefs.NotFound(fmt.Errorf("no images providing the requested platform %s found", platforms.FormatAll(platform))) + } + var incompleteImgs []string + for _, img := range imgs { + hasRequestedPlatform := false + err := i.walkImageManifests(ctx, img, func(platformImg *ImageManifest) error { + imgPlat, err := platformImg.ImagePlatform(ctx) + if err != nil { + if cerrdefs.IsNotFound(err) { + return nil + } + return errors.Wrapf(err, "failed to determine image platform") + } + + if !pm.Match(imgPlat) { + return nil + } + available, err := platformImg.CheckContentAvailable(ctx) + if err != nil { + return errors.Wrapf(err, "failed to determine image content availability for platform %s", platforms.FormatAll(platform)) + } + + if available { + hasRequestedPlatform = true + return nil + } + return nil + }) + if err != nil { + return errdefs.System(err) + } + if !hasRequestedPlatform { + incompleteImgs = append(incompleteImgs, imageFamiliarName(img)) + } + } + + msg := "" + switch len(incompleteImgs) { + case 0: + // Success - All images provide the requested platform. + return nil + case 1: + msg = "image %s was loaded, but doesn't provide the requested platform (%s)" + default: + msg = "images [%s] were loaded, but don't provide the requested platform (%s)" + } + + return errdefs.NotFound(fmt.Errorf(msg, strings.Join(incompleteImgs, ", "), platforms.FormatAll(platform))) +} diff --git a/daemon/containerd/image_load_test.go b/daemon/containerd/image_load_test.go new file mode 100644 index 0000000000..f68b2c680a --- /dev/null +++ b/daemon/containerd/image_load_test.go @@ -0,0 +1,111 @@ +package containerd + +import ( + "bytes" + "context" + "math/rand" + "os" + "path/filepath" + "testing" + + "github.com/containerd/containerd/content" + "github.com/containerd/containerd/content/local" + "github.com/containerd/containerd/namespaces" + "github.com/containerd/platforms" + "github.com/docker/docker/errdefs" + "github.com/docker/docker/internal/testutils/specialimage" + "github.com/docker/docker/pkg/archive" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestImageLoadMissing(t *testing.T) { + linuxAmd64 := ocispec.Platform{OS: "linux", Architecture: "amd64"} + linuxArm64 := ocispec.Platform{OS: "linux", Architecture: "arm64"} + linuxArmv5 := ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v5"} + + ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name()) + + store, err := local.NewLabeledStore(t.TempDir(), &memoryLabelStore{}) + assert.NilError(t, err) + + imgSvc := fakeImageService(t, ctx, store) + // Mock the daemon platform. + imgSvc.defaultPlatformOverride = platforms.Only(linuxAmd64) + + tryLoad := func(ctx context.Context, t *testing.T, dir string, platform ocispec.Platform) error { + tarRc, err := archive.Tar(dir, archive.Uncompressed) + assert.NilError(t, err) + defer tarRc.Close() + + buf := bytes.Buffer{} + + defer func() { + t.Log(buf.String()) + }() + + return imgSvc.LoadImage(ctx, tarRc, &platform, &buf, true) + } + + clearStore := func(ctx context.Context, t *testing.T) { + assert.NilError(t, store.Walk(ctx, func(info content.Info) error { + return store.Delete(ctx, info.Digest) + }), "failed to delete all content") + } + + t.Run("empty index", func(t *testing.T) { + imgDataDir := t.TempDir() + _, err := specialimage.EmptyIndex(imgDataDir) + assert.NilError(t, err) + + err = tryLoad(ctx, t, imgDataDir, linuxAmd64) + assert.Check(t, is.Error(err, "image emptyindex:latest was loaded, but doesn't provide the requested platform (linux/amd64)")) + assert.Check(t, is.ErrorType(err, errdefs.IsNotFound)) + }) + clearStore(ctx, t) + + t.Run("single platform", func(t *testing.T) { + imgDataDir := t.TempDir() + r := rand.NewSource(0x9127371238) + _, err := specialimage.RandomSinglePlatform(imgDataDir, linuxAmd64, r) + assert.NilError(t, err) + + err = tryLoad(ctx, t, imgDataDir, linuxArm64) + assert.Check(t, is.ErrorContains(err, "doesn't provide the requested platform (linux/arm64)")) + assert.Check(t, is.ErrorType(err, errdefs.IsNotFound)) + }) + + clearStore(ctx, t) + + t.Run("2 platform image", func(t *testing.T) { + imgDataDir := t.TempDir() + _, mfstDescs, err := specialimage.MultiPlatform(imgDataDir, "multiplatform:latest", []ocispec.Platform{linuxAmd64, linuxArm64}) + assert.NilError(t, err) + + t.Run("platform not included in index", func(t *testing.T) { + err = tryLoad(ctx, t, imgDataDir, linuxArmv5) + assert.Check(t, is.Error(err, "image multiplatform:latest was loaded, but doesn't provide the requested platform (linux/arm/v5)")) + assert.Check(t, is.ErrorType(err, errdefs.IsNotFound)) + }) + + clearStore(ctx, t) + + t.Run("platform blobs missing", func(t *testing.T) { + // Assumption: arm64 image is second in the index (implementation detail of specialimage.MultiPlatform) + mfstDesc := mfstDescs[1] + assert.Assert(t, mfstDesc.Platform.Architecture == linuxArm64.Architecture) + assert.Assert(t, mfstDesc.Platform.Variant == linuxArm64.Variant) + + t.Log(mfstDesc.Digest) + + // Delete arm64 manifest + mfstPath := filepath.Join(imgDataDir, "blobs/sha256", mfstDesc.Digest.Encoded()) + assert.NilError(t, os.Remove(mfstPath)) + + err = tryLoad(ctx, t, imgDataDir, linuxArm64) + assert.Check(t, is.ErrorContains(err, "requested platform (linux/arm64) found, but some content is missing")) + assert.Check(t, is.ErrorType(err, errdefs.IsNotFound)) + }) + }) +}