| // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: | |
| //go:build go1.22 | |
| package containerd | |
| import ( | |
| "context" | |
| "fmt" | |
| "path/filepath" | |
| "slices" | |
| "testing" | |
| containerdimages "github.com/containerd/containerd/images" | |
| "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" | |
| ) | |
| type pushTestCase struct { | |
| name string | |
| indexPlatforms []ocispec.Platform // all platforms supported by the image | |
| availablePlatforms []ocispec.Platform // platforms available locally | |
| requestPlatform *ocispec.Platform // platform requested by the client (not the platform selected for push!) | |
| check func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) | |
| daemonPlatform *ocispec.Platform | |
| } | |
| func TestImagePushIndex(t *testing.T) { | |
| ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name()) | |
| csDir := t.TempDir() | |
| store := &blobsDirContentStore{blobs: filepath.Join(csDir, "blobs/sha256")} | |
| linuxAmd64 := platforms.MustParse("linux/amd64") | |
| darwinArm64 := platforms.MustParse("darwin/arm64") | |
| windowsAmd64 := platforms.MustParse("windows/amd64") | |
| linuxArm64 := platforms.MustParse("linux/arm64") | |
| linuxArmv5 := platforms.MustParse("linux/arm/v5") | |
| linuxArmv7 := platforms.MustParse("linux/arm/v7") | |
| // Image service will have the daemon host platform mocked to linux/amd64. | |
| // Unless test cases specify a different platform. | |
| defaultDaemonPlatform := linuxAmd64 | |
| for _, tc := range []pushTestCase{ | |
| // No explicit platform requested | |
| { | |
| name: "none requested, all present", | |
| indexPlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| availablePlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| check: wholeIndexSelected, | |
| }, | |
| { | |
| name: "none requested, one present", | |
| indexPlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| availablePlatforms: []ocispec.Platform{linuxAmd64}, | |
| check: singleManifestSelected(linuxAmd64), | |
| }, | |
| { | |
| name: "none requested, two present, daemon platform available", | |
| indexPlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| availablePlatforms: []ocispec.Platform{linuxAmd64, darwinArm64}, | |
| check: singleManifestSelected(linuxAmd64), | |
| }, | |
| { | |
| name: "none requested, two present, daemon platform NOT available", | |
| indexPlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| availablePlatforms: []ocispec.Platform{darwinArm64, windowsAmd64}, | |
| check: multipleCandidates, | |
| }, | |
| // Specific platform requested | |
| { | |
| name: "linux/amd64 requested, all present", | |
| indexPlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| availablePlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| requestPlatform: &linuxAmd64, | |
| check: singleManifestSelected(linuxAmd64), | |
| }, | |
| { | |
| name: "linux/amd64 requested, but not present", | |
| indexPlatforms: []ocispec.Platform{linuxAmd64, darwinArm64, windowsAmd64}, | |
| availablePlatforms: []ocispec.Platform{darwinArm64, windowsAmd64}, | |
| requestPlatform: &linuxAmd64, | |
| check: candidateNotFound, | |
| }, | |
| // Variant tests | |
| { | |
| name: "linux/arm/v5 requested, but not in index", | |
| indexPlatforms: []ocispec.Platform{linuxAmd64, linuxArmv7}, | |
| availablePlatforms: []ocispec.Platform{linuxAmd64, linuxArmv7}, | |
| requestPlatform: &linuxArmv5, | |
| check: candidateNotFound, | |
| }, | |
| { | |
| name: "linux/arm/v5 requested, but not available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArm64, linuxArmv7}, | |
| requestPlatform: &linuxArmv5, | |
| check: candidateNotFound, | |
| }, | |
| { | |
| name: "linux/arm/v7 requested, but not available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArm64, linuxArmv5}, | |
| requestPlatform: &linuxArmv7, | |
| check: candidateNotFound, | |
| }, | |
| { | |
| name: "linux/arm/v7 requested on v7 daemon, but not available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArm64, linuxArmv5}, | |
| daemonPlatform: &linuxArmv7, | |
| requestPlatform: &linuxArmv7, | |
| check: candidateNotFound, | |
| }, | |
| { | |
| name: "linux/arm/v7 requested on v5 daemon, all available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| daemonPlatform: &linuxArmv5, | |
| requestPlatform: &linuxArmv7, | |
| check: singleManifestSelected(linuxArmv7), | |
| }, | |
| { | |
| name: "linux/arm/v5 requested on v7 daemon, all available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| daemonPlatform: &linuxArmv7, | |
| requestPlatform: &linuxArmv5, | |
| check: singleManifestSelected(linuxArmv5), | |
| }, | |
| { | |
| name: "none requested on v5 daemon, arm64 not available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArmv7, linuxArmv5}, | |
| daemonPlatform: &linuxArmv5, | |
| requestPlatform: nil, | |
| check: singleManifestSelected(linuxArmv5), | |
| }, | |
| { | |
| name: "none requested on v7 daemon, arm64 not available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArmv7, linuxArmv5}, | |
| daemonPlatform: &linuxArmv7, | |
| requestPlatform: nil, | |
| check: singleManifestSelected(linuxArmv7), | |
| }, | |
| { | |
| name: "none requested on v7 daemon, v7 not available", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv7, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArm64, linuxArmv5}, | |
| daemonPlatform: &linuxArmv7, | |
| requestPlatform: nil, | |
| check: singleManifestSelected(linuxArmv5), // Should it fail, because v5 can't be pushed? | |
| }, | |
| { | |
| name: "none requested on v7 daemon, v5 in index but not v7, all present", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArm64, linuxArmv5}, | |
| daemonPlatform: &linuxArmv7, | |
| requestPlatform: nil, | |
| check: wholeIndexSelected, | |
| }, | |
| { | |
| name: "none requested on v7 daemon, v5 in index but not v7, v5 present", | |
| indexPlatforms: []ocispec.Platform{linuxArm64, linuxArmv5}, | |
| availablePlatforms: []ocispec.Platform{linuxArmv5}, | |
| daemonPlatform: &linuxArmv7, | |
| requestPlatform: nil, | |
| check: singleManifestSelected(linuxArmv5), | |
| }, | |
| } { | |
| t.Run(tc.name, func(t *testing.T) { | |
| imgSvc := fakeImageService(t, ctx, store) | |
| // Mock the daemon platform. | |
| if tc.daemonPlatform != nil { | |
| imgSvc.defaultPlatformOverride = platforms.Only(*tc.daemonPlatform) | |
| } else { | |
| imgSvc.defaultPlatformOverride = platforms.Only(defaultDaemonPlatform) | |
| } | |
| idx, _, err := specialimage.MultiPlatform(csDir, "multiplatform:latest", tc.indexPlatforms) | |
| assert.NilError(t, err) | |
| imgs := imagesFromIndex(idx) | |
| assert.Assert(t, is.Len(imgs, 1)) | |
| img := imgs[0] | |
| _, err = imgSvc.images.Create(ctx, img) | |
| assert.NilError(t, err) | |
| for _, platform := range tc.indexPlatforms { | |
| if slices.ContainsFunc(tc.availablePlatforms, platforms.OnlyStrict(platform).Match) { | |
| continue | |
| } | |
| assert.NilError(t, deletePlatform(ctx, imgSvc, img, platform)) | |
| } | |
| desc, err := imgSvc.getPushDescriptor(ctx, img, tc.requestPlatform) | |
| tc.check(t, img, desc, err) | |
| }) | |
| } | |
| } | |
| func deletePlatform(ctx context.Context, imgSvc *ImageService, img containerdimages.Image, platform ocispec.Platform) error { | |
| var blobs []ocispec.Descriptor | |
| pm := platforms.OnlyStrict(platform) | |
| err := imgSvc.walkImageManifests(ctx, img, func(im *ImageManifest) error { | |
| imPlatform, err := im.ImagePlatform(ctx) | |
| if err != nil { | |
| return fmt.Errorf("failed to determine platform of image manifest %v: %w", im.Target(), err) | |
| } | |
| if !pm.Match(imPlatform) { | |
| return nil | |
| } | |
| return imgSvc.walkPresentChildren(ctx, im.Target(), func(ctx context.Context, d ocispec.Descriptor) error { | |
| blobs = append(blobs, d) | |
| return nil | |
| }) | |
| }) | |
| if err != nil { | |
| return fmt.Errorf("failed to walk image manifests: %w", err) | |
| } | |
| for _, d := range blobs { | |
| err := imgSvc.content.Delete(ctx, d.Digest) | |
| if err != nil { | |
| return fmt.Errorf("failed to delete blob %v: %w", d.Digest, err) | |
| } | |
| } | |
| return nil | |
| } | |
| // wholeIndexSelected asserts that the push descriptor candidate is for the whole index. | |
| func wholeIndexSelected(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) { | |
| assert.NilError(t, err) | |
| assert.Check(t, is.Equal(pushDescriptor.Digest, img.Target.Digest)) | |
| } | |
| // singleManifestSelected asserts that the push descriptor candidate is for a single platform-specific manifest. | |
| func singleManifestSelected(platform ocispec.Platform) func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) { | |
| pm := platforms.OnlyStrict(platform) | |
| return func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) { | |
| assert.NilError(t, err) | |
| assert.Assert(t, is.Equal(pushDescriptor.MediaType, ocispec.MediaTypeImageManifest), "the push descriptor isn't for a manifest") | |
| assert.Assert(t, pushDescriptor.Platform != nil, "the push descriptor doesn't have a platform") | |
| assert.Assert(t, pm.Match(*pushDescriptor.Platform), "the push descriptor isn't for the selected platform") | |
| } | |
| } | |
| // candidateNotFound asserts that the no matching candidate was found. | |
| func candidateNotFound(t *testing.T, _ containerdimages.Image, desc ocispec.Descriptor, err error) { | |
| assert.Check(t, errdefs.IsNotFound(err), "expected NotFound error, got %v, candidate: %v", err, desc.Platform) | |
| } | |
| // multipleCandidates asserts that multiple matching candidates were found and no decision could be made. | |
| func multipleCandidates(t *testing.T, _ containerdimages.Image, desc ocispec.Descriptor, err error) { | |
| assert.Check(t, errdefs.IsConflict(err), "expected Conflict error, got %v, candidate: %v", err, desc.Platform) | |
| } |