Currently the image export and load APIs can be used to export or load all
platforms for the image, or a single specified platform.
This commit updates the API so that it accepts a list of platforms to export or
load, thereby giving clients the ability to export only selected platforms of an
image into a tar file, or load selected platforms from a tar file.
Unit and integration tests were updated accordingly.
As this requires a daemon API change, the API version was bumped.
Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
| ... | ... |
@@ -10443,7 +10443,10 @@ paths: |
| 10443 | 10443 |
type: "string" |
| 10444 | 10444 |
required: true |
| 10445 | 10445 |
- name: "platform" |
| 10446 |
- type: "string" |
|
| 10446 |
+ type: "array" |
|
| 10447 |
+ items: |
|
| 10448 |
+ type: "string" |
|
| 10449 |
+ collectionFormat: "multi" |
|
| 10447 | 10450 |
in: "query" |
| 10448 | 10451 |
description: | |
| 10449 | 10452 |
JSON encoded OCI platform describing a platform which will be used |
| ... | ... |
@@ -10488,13 +10491,16 @@ paths: |
| 10488 | 10488 |
items: |
| 10489 | 10489 |
type: "string" |
| 10490 | 10490 |
- name: "platform" |
| 10491 |
- type: "string" |
|
| 10491 |
+ type: "array" |
|
| 10492 |
+ items: |
|
| 10493 |
+ type: "string" |
|
| 10494 |
+ collectionFormat: "multi" |
|
| 10492 | 10495 |
in: "query" |
| 10493 | 10496 |
description: | |
| 10494 |
- JSON encoded OCI platform describing a platform which will be used |
|
| 10495 |
- to select a platform-specific image to be saved if the image is |
|
| 10496 |
- multi-platform. |
|
| 10497 |
- If not provided, the full multi-platform image will be saved. |
|
| 10497 |
+ JSON encoded OCI platform(s) which will be used to select the |
|
| 10498 |
+ platform-specific image(s) to be saved if the image is |
|
| 10499 |
+ multi-platform. If not provided, the full multi-platform image |
|
| 10500 |
+ will be saved. |
|
| 10498 | 10501 |
|
| 10499 | 10502 |
Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
|
| 10500 | 10503 |
tags: ["Image"] |
| ... | ... |
@@ -10530,13 +10536,16 @@ paths: |
| 10530 | 10530 |
type: "boolean" |
| 10531 | 10531 |
default: false |
| 10532 | 10532 |
- name: "platform" |
| 10533 |
- type: "string" |
|
| 10533 |
+ type: "array" |
|
| 10534 |
+ items: |
|
| 10535 |
+ type: "string" |
|
| 10536 |
+ collectionFormat: "multi" |
|
| 10534 | 10537 |
in: "query" |
| 10535 | 10538 |
description: | |
| 10536 |
- JSON encoded OCI platform describing a platform which will be used |
|
| 10537 |
- to select a platform-specific image to be load if the image is |
|
| 10538 |
- multi-platform. |
|
| 10539 |
- If not provided, the full multi-platform image will be loaded. |
|
| 10539 |
+ JSON encoded OCI platform(s) which will be used to select the |
|
| 10540 |
+ platform-specific image(s) to load if the image is |
|
| 10541 |
+ multi-platform. If not provided, the full multi-platform image |
|
| 10542 |
+ will be loaded. |
|
| 10540 | 10543 |
|
| 10541 | 10544 |
Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
|
| 10542 | 10545 |
tags: ["Image"] |
| ... | ... |
@@ -31,8 +31,15 @@ import ( |
| 31 | 31 |
// outStream is the writer which the images are written to. |
| 32 | 32 |
// |
| 33 | 33 |
// TODO(thaJeztah): produce JSON stream progress response and image events; see https://github.com/moby/moby/issues/43910 |
| 34 |
-func (i *ImageService) ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error {
|
|
| 35 |
- pm := i.matchRequestedOrDefault(platforms.OnlyStrict, platform) |
|
| 34 |
+func (i *ImageService) ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error {
|
|
| 35 |
+ var pm platforms.MatchComparer |
|
| 36 |
+ |
|
| 37 |
+ // Get the platform matcher for the requested platforms |
|
| 38 |
+ if len(platformList) == 0 {
|
|
| 39 |
+ pm = matchAllWithPreference(i.hostPlatformMatcher()) |
|
| 40 |
+ } else {
|
|
| 41 |
+ pm = matchAnyWithPreference(i.hostPlatformMatcher(), platformList) |
|
| 42 |
+ } |
|
| 36 | 43 |
|
| 37 | 44 |
opts := []archive.ExportOpt{
|
| 38 | 45 |
archive.WithSkipNonDistributableBlobs(), |
| ... | ... |
@@ -65,8 +72,12 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform |
| 65 | 65 |
exportImage := func(ctx context.Context, img c8dimages.Image, ref reference.Named) error {
|
| 66 | 66 |
target := img.Target |
| 67 | 67 |
|
| 68 |
- if platform != nil {
|
|
| 69 |
- newTarget, err := i.getPushDescriptor(ctx, img, platform) |
|
| 68 |
+ // If a single platform is requested, export the manifest for the specific platform only |
|
| 69 |
+ // (single-level index). Otherwise export the full index (two-level, nested). Note that |
|
| 70 |
+ // since opts includes WithPlatform and WithSkipMissing, the index will contain the |
|
| 71 |
+ // requested platforms only, and only if they are available in the content store. |
|
| 72 |
+ if len(platformList) == 1 {
|
|
| 73 |
+ newTarget, err := i.getPushDescriptor(ctx, img, &platformList[0]) |
|
| 70 | 74 |
if err != nil {
|
| 71 | 75 |
return errors.Wrap(err, "no suitable export target found") |
| 72 | 76 |
} |
| ... | ... |
@@ -229,14 +240,23 @@ func (i *ImageService) leaseContent(ctx context.Context, store content.Store, de |
| 229 | 229 |
// LoadImage uploads a set of images into the repository. This is the |
| 230 | 230 |
// complement of ExportImage. The input stream is an uncompressed tar |
| 231 | 231 |
// ball containing images and metadata. |
| 232 |
-func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error {
|
|
| 232 |
+func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error {
|
|
| 233 | 233 |
decompressed, err := compression.DecompressStream(inTar) |
| 234 | 234 |
if err != nil {
|
| 235 | 235 |
return errors.Wrap(err, "failed to decompress input tar archive") |
| 236 | 236 |
} |
| 237 | 237 |
defer decompressed.Close() |
| 238 | 238 |
|
| 239 |
- pm := i.matchRequestedOrDefault(platforms.OnlyStrict, platform) |
|
| 239 |
+ specificPlatforms := len(platformList) > 0 |
|
| 240 |
+ |
|
| 241 |
+ // Get the platform matcher for the requested platforms |
|
| 242 |
+ var pm platforms.MatchComparer |
|
| 243 |
+ if specificPlatforms {
|
|
| 244 |
+ pm = platforms.Any(platformList...) |
|
| 245 |
+ } else {
|
|
| 246 |
+ // All platforms |
|
| 247 |
+ pm = matchAllWithPreference(i.hostPlatformMatcher()) |
|
| 248 |
+ } |
|
| 240 | 249 |
|
| 241 | 250 |
opts := []containerd.ImportOpt{
|
| 242 | 251 |
containerd.WithImportPlatform(pm), |
| ... | ... |
@@ -266,16 +286,19 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf |
| 266 | 266 |
}), |
| 267 | 267 |
} |
| 268 | 268 |
|
| 269 |
- if platform == nil {
|
|
| 269 |
+ if !specificPlatforms {
|
|
| 270 | 270 |
// Allow variants to be missing if no specific platform is requested. |
| 271 | 271 |
opts = append(opts, containerd.WithSkipMissing()) |
| 272 | 272 |
} |
| 273 | 273 |
|
| 274 | 274 |
imgs, err := i.client.Import(ctx, decompressed, opts...) |
| 275 | 275 |
if err != nil {
|
| 276 |
- if platform != nil {
|
|
| 277 |
- p := platforms.FormatAll(*platform) |
|
| 278 |
- log.G(ctx).WithFields(log.Fields{"error": err, "platform": p}).Debug("failed to import image to containerd")
|
|
| 276 |
+ if specificPlatforms {
|
|
| 277 |
+ platformNames := make([]string, 0, len(platformList)) |
|
| 278 |
+ for _, p := range platformList {
|
|
| 279 |
+ platformNames = append(platformNames, platforms.FormatAll(p)) |
|
| 280 |
+ } |
|
| 281 |
+ log.G(ctx).WithFields(log.Fields{"error": err, "platform(s)": platformNames}).Debug("failed to import image to containerd")
|
|
| 279 | 282 |
|
| 280 | 283 |
// Note: ErrEmptyWalk will not be returned in most cases as |
| 281 | 284 |
// index.json will contain a descriptor of the actual OCI index or |
| ... | ... |
@@ -284,22 +307,26 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf |
| 284 | 284 |
// doesn't have a platform set, so it won't be filtered out by the |
| 285 | 285 |
// FilterPlatform containerd handler. |
| 286 | 286 |
if errors.Is(err, c8dimages.ErrEmptyWalk) {
|
| 287 |
- return errdefs.NotFound(errors.Wrapf(err, "requested platform (%s) not found", p)) |
|
| 287 |
+ return errdefs.NotFound(errors.Wrapf(err, "requested platform(s) (%v) not found", platformNames)) |
|
| 288 | 288 |
} |
| 289 | 289 |
if cerrdefs.IsNotFound(err) {
|
| 290 |
- return errdefs.NotFound(errors.Wrapf(err, "requested platform (%s) found, but some content is missing", p)) |
|
| 290 |
+ return errdefs.NotFound(errors.Wrapf(err, "requested platform(s) (%v) found, but some content is missing", platformNames)) |
|
| 291 | 291 |
} |
| 292 | 292 |
} |
| 293 | 293 |
log.G(ctx).WithError(err).Debug("failed to import image to containerd")
|
| 294 | 294 |
return errdefs.System(err) |
| 295 | 295 |
} |
| 296 | 296 |
|
| 297 |
- if platform != nil {
|
|
| 298 |
- // Verify that the requested platform is available for the loaded images. |
|
| 297 |
+ if specificPlatforms {
|
|
| 298 |
+ // Verify that the requested platform(s) are available for the loaded images. |
|
| 299 | 299 |
// While the ideal behavior here would be to verify whether the input |
| 300 | 300 |
// archive actually supplied them, we're not able to determine that |
| 301 | 301 |
// as the imported index is not returned by the import operation. |
| 302 |
- if err := i.verifyImagesProvidePlatform(ctx, imgs, *platform, pm); err != nil {
|
|
| 302 |
+ platformNames := make([]string, 0, len(platformList)) |
|
| 303 |
+ for _, p := range platformList {
|
|
| 304 |
+ platformNames = append(platformNames, platforms.FormatAll(p)) |
|
| 305 |
+ } |
|
| 306 |
+ if err := i.verifyImagesProvidePlatform(ctx, imgs, platformNames, pm); err != nil {
|
|
| 303 | 307 |
return err |
| 304 | 308 |
} |
| 305 | 309 |
} |
| ... | ... |
@@ -308,7 +335,7 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf |
| 308 | 308 |
// Unpack only an image of the host platform |
| 309 | 309 |
unpackPm := i.hostPlatformMatcher() |
| 310 | 310 |
// If a load of specific platform is requested, unpack it |
| 311 |
- if platform != nil {
|
|
| 311 |
+ if specificPlatforms {
|
|
| 312 | 312 |
unpackPm = pm |
| 313 | 313 |
} |
| 314 | 314 |
|
| ... | ... |
@@ -378,9 +405,9 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf |
| 378 | 378 |
|
| 379 | 379 |
// verifyImagesProvidePlatform checks if the requested platform is loaded. |
| 380 | 380 |
// If the requested platform is not loaded, it returns an error. |
| 381 |
-func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c8dimages.Image, platform ocispec.Platform, pm platforms.Matcher) error {
|
|
| 381 |
+func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c8dimages.Image, platformNames []string, pm platforms.Matcher) error {
|
|
| 382 | 382 |
if len(imgs) == 0 {
|
| 383 |
- return errdefs.NotFound(fmt.Errorf("no images providing the requested platform %s found", platforms.FormatAll(platform)))
|
|
| 383 |
+ return errdefs.NotFound(fmt.Errorf("no images providing the requested platform(s) found: %v", platformNames))
|
|
| 384 | 384 |
} |
| 385 | 385 |
var incompleteImgs []string |
| 386 | 386 |
for _, img := range imgs {
|
| ... | ... |
@@ -399,7 +426,7 @@ func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c |
| 399 | 399 |
} |
| 400 | 400 |
available, err := platformImg.CheckContentAvailable(ctx) |
| 401 | 401 |
if err != nil {
|
| 402 |
- return errors.Wrapf(err, "failed to determine image content availability for platform %s", platforms.FormatAll(platform)) |
|
| 402 |
+ return errors.Wrapf(err, "failed to determine image content availability for platform(s) %s", platformNames) |
|
| 403 | 403 |
} |
| 404 | 404 |
|
| 405 | 405 |
if available {
|
| ... | ... |
@@ -427,5 +454,5 @@ func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c |
| 427 | 427 |
msg = "images [%s] were loaded, but don't provide the requested platform (%s)" |
| 428 | 428 |
} |
| 429 | 429 |
|
| 430 |
- return errdefs.NotFound(fmt.Errorf(msg, strings.Join(incompleteImgs, ", "), platforms.FormatAll(platform))) |
|
| 430 |
+ return errdefs.NotFound(fmt.Errorf(msg, strings.Join(incompleteImgs, ", "), platformNames)) |
|
| 431 | 431 |
} |
| ... | ... |
@@ -3,6 +3,7 @@ package containerd |
| 3 | 3 |
import ( |
| 4 | 4 |
"bytes" |
| 5 | 5 |
"context" |
| 6 |
+ "fmt" |
|
| 6 | 7 |
"math/rand" |
| 7 | 8 |
"os" |
| 8 | 9 |
"path/filepath" |
| ... | ... |
@@ -16,15 +17,18 @@ import ( |
| 16 | 16 |
"github.com/docker/docker/internal/testutils/labelstore" |
| 17 | 17 |
"github.com/docker/docker/internal/testutils/specialimage" |
| 18 | 18 |
"github.com/moby/go-archive" |
| 19 |
+ "github.com/moby/moby/api/types/backend" |
|
| 20 |
+ "github.com/moby/moby/api/types/image" |
|
| 19 | 21 |
ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
| 20 | 22 |
"gotest.tools/v3/assert" |
| 21 | 23 |
is "gotest.tools/v3/assert/cmp" |
| 22 | 24 |
) |
| 23 | 25 |
|
| 24 |
-func TestImageLoadMissing(t *testing.T) {
|
|
| 26 |
+func TestImageLoad(t *testing.T) {
|
|
| 25 | 27 |
linuxAmd64 := ocispec.Platform{OS: "linux", Architecture: "amd64"}
|
| 26 | 28 |
linuxArm64 := ocispec.Platform{OS: "linux", Architecture: "arm64"}
|
| 27 | 29 |
linuxArmv5 := ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v5"}
|
| 30 |
+ linuxRiscv64 := ocispec.Platform{OS: "linux", Architecture: "riskv64"}
|
|
| 28 | 31 |
|
| 29 | 32 |
ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name()) |
| 30 | 33 |
|
| ... | ... |
@@ -35,7 +39,7 @@ func TestImageLoadMissing(t *testing.T) {
|
| 35 | 35 |
// Mock the daemon platform. |
| 36 | 36 |
imgSvc.defaultPlatformOverride = platforms.Only(linuxAmd64) |
| 37 | 37 |
|
| 38 |
- tryLoad := func(ctx context.Context, t *testing.T, dir string, platform ocispec.Platform) error {
|
|
| 38 |
+ tryLoad := func(ctx context.Context, t *testing.T, dir string, platformList []ocispec.Platform) error {
|
|
| 39 | 39 |
tarRc, err := archive.Tar(dir, archive.Uncompressed) |
| 40 | 40 |
assert.NilError(t, err) |
| 41 | 41 |
defer tarRc.Close() |
| ... | ... |
@@ -46,10 +50,19 @@ func TestImageLoadMissing(t *testing.T) {
|
| 46 | 46 |
t.Log(buf.String()) |
| 47 | 47 |
}() |
| 48 | 48 |
|
| 49 |
- return imgSvc.LoadImage(ctx, tarRc, &platform, &buf, true) |
|
| 49 |
+ return imgSvc.LoadImage(ctx, tarRc, platformList, &buf, true) |
|
| 50 | 50 |
} |
| 51 | 51 |
|
| 52 |
- clearStore := func(ctx context.Context, t *testing.T) {
|
|
| 52 |
+ cleanup := func(ctx context.Context, t *testing.T) {
|
|
| 53 |
+ // Remove all existing images to start fresh |
|
| 54 |
+ images, err := imgSvc.Images(ctx, image.ListOptions{})
|
|
| 55 |
+ assert.NilError(t, err) |
|
| 56 |
+ for _, img := range images {
|
|
| 57 |
+ _, err := imgSvc.ImageDelete(ctx, img.ID, image.RemoveOptions{PruneChildren: true})
|
|
| 58 |
+ assert.NilError(t, err) |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ // Remove all content from the store |
|
| 53 | 62 |
assert.NilError(t, store.Walk(ctx, func(info content.Info) error {
|
| 54 | 63 |
return store.Delete(ctx, info.Digest) |
| 55 | 64 |
}), "failed to delete all content") |
| ... | ... |
@@ -60,39 +73,64 @@ func TestImageLoadMissing(t *testing.T) {
|
| 60 | 60 |
_, err := specialimage.EmptyIndex(imgDataDir) |
| 61 | 61 |
assert.NilError(t, err) |
| 62 | 62 |
|
| 63 |
- err = tryLoad(ctx, t, imgDataDir, linuxAmd64) |
|
| 64 |
- assert.Check(t, is.Error(err, "image emptyindex:latest was loaded, but doesn't provide the requested platform (linux/amd64)")) |
|
| 63 |
+ err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxAmd64})
|
|
| 64 |
+ assert.Check(t, is.Error(err, "image emptyindex:latest was loaded, but doesn't provide the requested platform ([linux/amd64])")) |
|
| 65 | 65 |
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
| 66 | 66 |
}) |
| 67 |
- clearStore(ctx, t) |
|
| 67 |
+ cleanup(ctx, t) |
|
| 68 | 68 |
|
| 69 | 69 |
t.Run("single platform", func(t *testing.T) {
|
| 70 | 70 |
imgDataDir := t.TempDir() |
| 71 | 71 |
r := rand.NewSource(0x9127371238) |
| 72 |
- _, err := specialimage.RandomSinglePlatform(imgDataDir, linuxAmd64, r) |
|
| 72 |
+ _, err = specialimage.RandomSinglePlatform(imgDataDir, linuxAmd64, r) |
|
| 73 | 73 |
assert.NilError(t, err) |
| 74 | 74 |
|
| 75 |
- err = tryLoad(ctx, t, imgDataDir, linuxArm64) |
|
| 76 |
- assert.Check(t, is.ErrorContains(err, "doesn't provide the requested platform (linux/arm64)")) |
|
| 75 |
+ platforms := []ocispec.Platform{linuxAmd64}
|
|
| 76 |
+ err = tryLoad(ctx, t, imgDataDir, platforms) |
|
| 77 |
+ assert.NilError(t, err) |
|
| 78 |
+ |
|
| 79 |
+ err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxArm64})
|
|
| 80 |
+ assert.Check(t, is.ErrorContains(err, "doesn't provide the requested platform ([linux/arm64])")) |
|
| 77 | 81 |
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
| 78 | 82 |
}) |
| 83 |
+ cleanup(ctx, t) |
|
| 79 | 84 |
|
| 80 |
- clearStore(ctx, t) |
|
| 81 |
- |
|
| 82 |
- t.Run("2 platform image", func(t *testing.T) {
|
|
| 85 |
+ t.Run("multi-platform image", func(t *testing.T) {
|
|
| 83 | 86 |
imgDataDir := t.TempDir() |
| 84 |
- _, mfstDescs, err := specialimage.MultiPlatform(imgDataDir, "multiplatform:latest", []ocispec.Platform{linuxAmd64, linuxArm64})
|
|
| 87 |
+ imgRef := "multiplatform:latest" |
|
| 88 |
+ _, mfstDescs, err := specialimage.MultiPlatform(imgDataDir, imgRef, []ocispec.Platform{linuxAmd64, linuxArm64, linuxRiscv64})
|
|
| 85 | 89 |
assert.NilError(t, err) |
| 86 | 90 |
|
| 91 |
+ t.Run("one platform in index", func(t *testing.T) {
|
|
| 92 |
+ platforms := []ocispec.Platform{linuxAmd64}
|
|
| 93 |
+ err = tryLoad(ctx, t, imgDataDir, platforms) |
|
| 94 |
+ assert.NilError(t, err) |
|
| 95 |
+ |
|
| 96 |
+ // verify that the loaded image has the correct platform |
|
| 97 |
+ err = verifyImagePlatforms(ctx, imgSvc, imgRef, platforms) |
|
| 98 |
+ assert.NilError(t, err) |
|
| 99 |
+ }) |
|
| 100 |
+ cleanup(ctx, t) |
|
| 101 |
+ |
|
| 102 |
+ t.Run("all platforms in index", func(t *testing.T) {
|
|
| 103 |
+ platforms := []ocispec.Platform{linuxAmd64, linuxArm64, linuxRiscv64}
|
|
| 104 |
+ err = tryLoad(ctx, t, imgDataDir, platforms) |
|
| 105 |
+ assert.NilError(t, err) |
|
| 106 |
+ |
|
| 107 |
+ // verify that the loaded image has the correct platforms |
|
| 108 |
+ err = verifyImagePlatforms(ctx, imgSvc, imgRef, platforms) |
|
| 109 |
+ assert.NilError(t, err) |
|
| 110 |
+ }) |
|
| 111 |
+ cleanup(ctx, t) |
|
| 112 |
+ |
|
| 87 | 113 |
t.Run("platform not included in index", func(t *testing.T) {
|
| 88 |
- err = tryLoad(ctx, t, imgDataDir, linuxArmv5) |
|
| 89 |
- assert.Check(t, is.Error(err, "image multiplatform:latest was loaded, but doesn't provide the requested platform (linux/arm/v5)")) |
|
| 114 |
+ err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxArmv5})
|
|
| 115 |
+ assert.Check(t, is.Error(err, "image multiplatform:latest was loaded, but doesn't provide the requested platform ([linux/arm/v5])")) |
|
| 90 | 116 |
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
| 91 | 117 |
}) |
| 118 |
+ cleanup(ctx, t) |
|
| 92 | 119 |
|
| 93 |
- clearStore(ctx, t) |
|
| 94 |
- |
|
| 95 |
- t.Run("platform blobs missing", func(t *testing.T) {
|
|
| 120 |
+ t.Run("platform included but blobs missing", func(t *testing.T) {
|
|
| 96 | 121 |
// Assumption: arm64 image is second in the index (implementation detail of specialimage.MultiPlatform) |
| 97 | 122 |
mfstDesc := mfstDescs[1] |
| 98 | 123 |
assert.Assert(t, mfstDesc.Platform.Architecture == linuxArm64.Architecture) |
| ... | ... |
@@ -104,9 +142,37 @@ func TestImageLoadMissing(t *testing.T) {
|
| 104 | 104 |
mfstPath := filepath.Join(imgDataDir, "blobs/sha256", mfstDesc.Digest.Encoded()) |
| 105 | 105 |
assert.NilError(t, os.Remove(mfstPath)) |
| 106 | 106 |
|
| 107 |
- err = tryLoad(ctx, t, imgDataDir, linuxArm64) |
|
| 108 |
- assert.Check(t, is.ErrorContains(err, "requested platform (linux/arm64) found, but some content is missing")) |
|
| 107 |
+ err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxArm64})
|
|
| 108 |
+ assert.Check(t, is.ErrorContains(err, "requested platform(s) ([linux/arm64]) found, but some content is missing")) |
|
| 109 | 109 |
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
| 110 | 110 |
}) |
| 111 |
+ cleanup(ctx, t) |
|
| 111 | 112 |
}) |
| 112 | 113 |
} |
| 114 |
+ |
|
| 115 |
+func verifyImagePlatforms(ctx context.Context, imgSvc *ImageService, imgRef string, expectedPlatforms []ocispec.Platform) error {
|
|
| 116 |
+ // get the manifest(s) for the image |
|
| 117 |
+ img, err := imgSvc.ImageInspect(ctx, imgRef, backend.ImageInspectOpts{Manifests: true})
|
|
| 118 |
+ if err != nil {
|
|
| 119 |
+ return err |
|
| 120 |
+ } |
|
| 121 |
+ // verify that the image manifest has the expected platforms |
|
| 122 |
+ for _, ep := range expectedPlatforms {
|
|
| 123 |
+ want := platforms.FormatAll(ep) |
|
| 124 |
+ found := false |
|
| 125 |
+ for _, m := range img.Manifests {
|
|
| 126 |
+ if m.Descriptor.Platform != nil {
|
|
| 127 |
+ got := platforms.FormatAll(*m.Descriptor.Platform) |
|
| 128 |
+ if got == want {
|
|
| 129 |
+ found = true |
|
| 130 |
+ break |
|
| 131 |
+ } |
|
| 132 |
+ } |
|
| 133 |
+ } |
|
| 134 |
+ if !found {
|
|
| 135 |
+ return fmt.Errorf("expected platform %q not found in loaded images", want)
|
|
| 136 |
+ } |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ return nil |
|
| 140 |
+} |
| ... | ... |
@@ -31,12 +31,17 @@ func TestImageMultiplatformSaveShallowWithNative(t *testing.T) {
|
| 31 | 31 |
Architecture: "arm64", |
| 32 | 32 |
} |
| 33 | 33 |
|
| 34 |
+ riscv64 := platforms.Platform{
|
|
| 35 |
+ OS: "linux", |
|
| 36 |
+ Architecture: "riscv64", |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 34 | 39 |
imgSvc := fakeImageService(t, ctx, store) |
| 35 | 40 |
// Mock the native platform. |
| 36 | 41 |
imgSvc.defaultPlatformOverride = platforms.Only(native) |
| 37 | 42 |
|
| 38 | 43 |
idx, _, err := specialimage.PartialMultiPlatform(contentDir, "partial-with-native:latest", specialimage.PartialOpts{
|
| 39 |
- Stored: []ocispec.Platform{native},
|
|
| 44 |
+ Stored: []ocispec.Platform{native, riscv64},
|
|
| 40 | 45 |
Missing: []ocispec.Platform{arm64},
|
| 41 | 46 |
}) |
| 42 | 47 |
assert.NilError(t, err) |
| ... | ... |
@@ -49,13 +54,21 @@ func TestImageMultiplatformSaveShallowWithNative(t *testing.T) {
|
| 49 | 49 |
assert.NilError(t, err) |
| 50 | 50 |
}) |
| 51 | 51 |
t.Run("export native", func(t *testing.T) {
|
| 52 |
- err = imgSvc.ExportImage(ctx, []string{img.Name}, &native, io.Discard)
|
|
| 52 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{native}, io.Discard)
|
|
| 53 |
+ assert.NilError(t, err) |
|
| 54 |
+ }) |
|
| 55 |
+ t.Run("export multiple platforms", func(t *testing.T) {
|
|
| 56 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{native, riscv64}, io.Discard)
|
|
| 53 | 57 |
assert.NilError(t, err) |
| 54 | 58 |
}) |
| 55 | 59 |
t.Run("export missing", func(t *testing.T) {
|
| 56 |
- err = imgSvc.ExportImage(ctx, []string{img.Name}, &arm64, io.Discard)
|
|
| 60 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64}, io.Discard)
|
|
| 57 | 61 |
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
| 58 | 62 |
}) |
| 63 |
+ t.Run("export multiple platforms with some missing", func(t *testing.T) {
|
|
| 64 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64, riscv64}, io.Discard)
|
|
| 65 |
+ assert.NilError(t, err) |
|
| 66 |
+ }) |
|
| 59 | 67 |
} |
| 60 | 68 |
|
| 61 | 69 |
func TestImageMultiplatformSaveShallowWithoutNative(t *testing.T) {
|
| ... | ... |
@@ -74,12 +87,22 @@ func TestImageMultiplatformSaveShallowWithoutNative(t *testing.T) {
|
| 74 | 74 |
Architecture: "arm64", |
| 75 | 75 |
} |
| 76 | 76 |
|
| 77 |
+ riscv64 := platforms.Platform{
|
|
| 78 |
+ OS: "linux", |
|
| 79 |
+ Architecture: "riscv64", |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ s390x := platforms.Platform{
|
|
| 83 |
+ OS: "linux", |
|
| 84 |
+ Architecture: "s390x", |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 77 | 87 |
imgSvc := fakeImageService(t, ctx, store) |
| 78 | 88 |
// Mock the native platform. |
| 79 | 89 |
imgSvc.defaultPlatformOverride = platforms.Only(native) |
| 80 | 90 |
|
| 81 | 91 |
idx, _, err := specialimage.PartialMultiPlatform(contentDir, "partial-without-native:latest", specialimage.PartialOpts{
|
| 82 |
- Stored: []ocispec.Platform{arm64},
|
|
| 92 |
+ Stored: []ocispec.Platform{arm64, riscv64},
|
|
| 83 | 93 |
Missing: []ocispec.Platform{native},
|
| 84 | 94 |
}) |
| 85 | 95 |
assert.NilError(t, err) |
| ... | ... |
@@ -93,11 +116,23 @@ func TestImageMultiplatformSaveShallowWithoutNative(t *testing.T) {
|
| 93 | 93 |
assert.NilError(t, err) |
| 94 | 94 |
}) |
| 95 | 95 |
t.Run("export native", func(t *testing.T) {
|
| 96 |
- err = imgSvc.ExportImage(ctx, []string{img.Name}, &native, io.Discard)
|
|
| 96 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{native}, io.Discard)
|
|
| 97 | 97 |
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
| 98 | 98 |
}) |
| 99 | 99 |
t.Run("export arm64", func(t *testing.T) {
|
| 100 |
- err = imgSvc.ExportImage(ctx, []string{img.Name}, &arm64, io.Discard)
|
|
| 100 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64}, io.Discard)
|
|
| 101 |
+ assert.NilError(t, err) |
|
| 102 |
+ }) |
|
| 103 |
+ t.Run("export multiple platforms", func(t *testing.T) {
|
|
| 104 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64, riscv64}, io.Discard)
|
|
| 101 | 105 |
assert.NilError(t, err) |
| 102 | 106 |
}) |
| 107 |
+ t.Run("export multiple platforms with some missing", func(t *testing.T) {
|
|
| 108 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64, native}, io.Discard)
|
|
| 109 |
+ assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
|
| 110 |
+ }) |
|
| 111 |
+ t.Run("export non existing platform", func(t *testing.T) {
|
|
| 112 |
+ err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{s390x}, io.Discard)
|
|
| 113 |
+ assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) |
|
| 114 |
+ }) |
|
| 103 | 115 |
} |
| ... | ... |
@@ -5,13 +5,14 @@ import ( |
| 5 | 5 |
ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
| 6 | 6 |
) |
| 7 | 7 |
|
| 8 |
+// allPlatformsWithPreferenceMatcher returns a platform matcher that matches all |
|
| 9 |
+// platforms but orders platforms to match the preferred matcher first. |
|
| 10 |
+// It implements the platforms.MatchComparer interface. |
|
| 8 | 11 |
type allPlatformsWithPreferenceMatcher struct {
|
| 9 | 12 |
preferred platforms.MatchComparer |
| 10 | 13 |
} |
| 11 | 14 |
|
| 12 |
-// matchAllWithPreference will return a platform matcher that matches all |
|
| 13 |
-// platforms but will order platforms matching the preferred matcher first. |
|
| 14 |
-func matchAllWithPreference(preferred platforms.MatchComparer) platforms.MatchComparer {
|
|
| 15 |
+func matchAllWithPreference(preferred platforms.MatchComparer) allPlatformsWithPreferenceMatcher {
|
|
| 15 | 16 |
return allPlatformsWithPreferenceMatcher{
|
| 16 | 17 |
preferred: preferred, |
| 17 | 18 |
} |
| ... | ... |
@@ -25,6 +26,29 @@ func (c allPlatformsWithPreferenceMatcher) Less(p1, p2 ocispec.Platform) bool {
|
| 25 | 25 |
return c.preferred.Less(p1, p2) |
| 26 | 26 |
} |
| 27 | 27 |
|
| 28 |
+// platformsWithPreferenceMatcher is a platform matcher that matches any of the |
|
| 29 |
+// given platforms, but orders platforms to match the preferred matcher first. |
|
| 30 |
+// It implements the platforms.MatchComparer interface. |
|
| 31 |
+type platformsWithPreferenceMatcher struct {
|
|
| 32 |
+ platformList []ocispec.Platform |
|
| 33 |
+ preferred platforms.MatchComparer |
|
| 34 |
+} |
|
| 35 |
+ |
|
| 36 |
+func matchAnyWithPreference(preferred platforms.MatchComparer, platformList []ocispec.Platform) platformsWithPreferenceMatcher {
|
|
| 37 |
+ return platformsWithPreferenceMatcher{
|
|
| 38 |
+ platformList: platformList, |
|
| 39 |
+ preferred: preferred, |
|
| 40 |
+ } |
|
| 41 |
+} |
|
| 42 |
+ |
|
| 43 |
+func (c platformsWithPreferenceMatcher) Match(p ocispec.Platform) bool {
|
|
| 44 |
+ return platforms.Any(c.platformList...).Match(p) |
|
| 45 |
+} |
|
| 46 |
+ |
|
| 47 |
+func (c platformsWithPreferenceMatcher) Less(p1, p2 ocispec.Platform) bool {
|
|
| 48 |
+ return c.preferred.Less(p1, p2) |
|
| 49 |
+} |
|
| 50 |
+ |
|
| 28 | 51 |
// platformMatcherWithRequestedPlatform is a platform matcher that also |
| 29 | 52 |
// contains the platform that was requested by the user in the context |
| 30 | 53 |
// in which the matcher was created. |
| ... | ... |
@@ -36,6 +60,7 @@ type platformMatcherWithRequestedPlatform struct {
|
| 36 | 36 |
|
| 37 | 37 |
type matchComparerProvider func(ocispec.Platform) platforms.MatchComparer |
| 38 | 38 |
|
| 39 |
+// TODO(ctalledo): move this to a more appropriate place (e.g., next to the other ImageService methods). |
|
| 39 | 40 |
func (i *ImageService) matchRequestedOrDefault( |
| 40 | 41 |
fpm matchComparerProvider, // function to create a platform matcher if platform is not nil |
| 41 | 42 |
platform *ocispec.Platform, // input platform, nil if not specified |
| ... | ... |
@@ -1,6 +1,7 @@ |
| 1 | 1 |
package containerd |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 |
+ "reflect" |
|
| 4 | 5 |
"runtime" |
| 5 | 6 |
"testing" |
| 6 | 7 |
|
| ... | ... |
@@ -167,3 +168,34 @@ func testOnlyAndOnlyStrict(t *testing.T, daemonPlatform platforms.MatchComparer, |
| 167 | 167 |
} |
| 168 | 168 |
}) |
| 169 | 169 |
} |
| 170 |
+func TestPlatformsWithPreferenceMatcher(t *testing.T) {
|
|
| 171 |
+ platformList := []ocispec.Platform{
|
|
| 172 |
+ pLinuxAmd64, |
|
| 173 |
+ pLinuxArmv5, |
|
| 174 |
+ pLinuxArmv6, |
|
| 175 |
+ pLinuxArm64, |
|
| 176 |
+ pWindowsAmd64, |
|
| 177 |
+ } |
|
| 178 |
+ |
|
| 179 |
+ // Use pLinuxArm64 as the preferred platform |
|
| 180 |
+ preferred := platforms.Only(pLinuxArm64) |
|
| 181 |
+ matcher := matchAnyWithPreference(preferred, platformList) |
|
| 182 |
+ |
|
| 183 |
+ // Should match all platforms in the list |
|
| 184 |
+ for _, p := range platformList {
|
|
| 185 |
+ assert.Assert(t, matcher.Match(p), "matcher should match platform: %v", platforms.Format(p)) |
|
| 186 |
+ } |
|
| 187 |
+ |
|
| 188 |
+ // Should not match a platform not in the list |
|
| 189 |
+ notInList := ocispec.Platform{OS: "linux", Architecture: "s390x"}
|
|
| 190 |
+ assert.Assert(t, !matcher.Match(notInList), "matcher should not match platform: %v", platforms.Format(notInList)) |
|
| 191 |
+ |
|
| 192 |
+ // Test Less: preferred should be less than others |
|
| 193 |
+ for _, p := range platformList {
|
|
| 194 |
+ if reflect.DeepEqual(p, pLinuxArm64) {
|
|
| 195 |
+ continue |
|
| 196 |
+ } |
|
| 197 |
+ assert.Assert(t, matcher.Less(pLinuxArm64, p), "preferred platform should be less than %v", platforms.Format(p)) |
|
| 198 |
+ assert.Assert(t, !matcher.Less(p, pLinuxArm64), "%v should not be less than preferred platform", platforms.Format(p)) |
|
| 199 |
+ } |
|
| 200 |
+} |
| ... | ... |
@@ -30,8 +30,8 @@ type ImageService interface {
|
| 30 | 30 |
PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error |
| 31 | 31 |
CreateImage(ctx context.Context, config []byte, parent string, contentStoreDigest digest.Digest) (builder.Image, error) |
| 32 | 32 |
ImageDelete(ctx context.Context, imageRef string, options imagetype.RemoveOptions) ([]imagetype.DeleteResponse, error) |
| 33 |
- ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error |
|
| 34 |
- LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error |
|
| 33 |
+ ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error |
|
| 34 |
+ LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error |
|
| 35 | 35 |
Images(ctx context.Context, opts imagetype.ListOptions) ([]*imagetype.Summary, error) |
| 36 | 36 |
LogImageEvent(ctx context.Context, imageID, refName string, action events.Action) |
| 37 | 37 |
CountImages(ctx context.Context) int |
| ... | ... |
@@ -4,8 +4,10 @@ import ( |
| 4 | 4 |
"context" |
| 5 | 5 |
"io" |
| 6 | 6 |
|
| 7 |
+ "github.com/docker/docker/errdefs" |
|
| 7 | 8 |
"github.com/docker/docker/image/tarexport" |
| 8 | 9 |
ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
| 10 |
+ "github.com/pkg/errors" |
|
| 9 | 11 |
) |
| 10 | 12 |
|
| 11 | 13 |
// ExportImage exports a list of images to the given output stream. The |
| ... | ... |
@@ -13,7 +15,15 @@ import ( |
| 13 | 13 |
// stream. All images with the given tag and all versions containing |
| 14 | 14 |
// the same tag are exported. names is the set of tags to export, and |
| 15 | 15 |
// outStream is the writer which the images are written to. |
| 16 |
-func (i *ImageService) ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error {
|
|
| 16 |
+func (i *ImageService) ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error {
|
|
| 17 |
+ var platform *ocispec.Platform |
|
| 18 |
+ |
|
| 19 |
+ if len(platformList) > 1 {
|
|
| 20 |
+ return errdefs.InvalidParameter(errors.New("multiple platforms not supported for this image store; use a multi-platform image store such as containerd-snapshotter"))
|
|
| 21 |
+ } else if len(platformList) == 1 {
|
|
| 22 |
+ platform = &platformList[0] |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 17 | 25 |
imageExporter := tarexport.NewTarExporter(i.imageStore, i.layerStore, i.referenceStore, i, platform) |
| 18 | 26 |
return imageExporter.Save(ctx, names, outStream) |
| 19 | 27 |
} |
| ... | ... |
@@ -21,7 +31,15 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform |
| 21 | 21 |
// LoadImage uploads a set of images into the repository. This is the |
| 22 | 22 |
// complement of ExportImage. The input stream is an uncompressed tar |
| 23 | 23 |
// ball containing images and metadata. |
| 24 |
-func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error {
|
|
| 24 |
+func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error {
|
|
| 25 |
+ var platform *ocispec.Platform |
|
| 26 |
+ |
|
| 27 |
+ if len(platformList) > 1 {
|
|
| 28 |
+ return errdefs.InvalidParameter(errors.New("multiple platforms not supported for this image store; use a multi-platform image store such as containerd-snapshotter"))
|
|
| 29 |
+ } else if len(platformList) == 1 {
|
|
| 30 |
+ platform = &platformList[0] |
|
| 31 |
+ } |
|
| 32 |
+ |
|
| 25 | 33 |
imageExporter := tarexport.NewTarExporter(i.imageStore, i.layerStore, i.referenceStore, i, platform) |
| 26 | 34 |
return imageExporter.Load(ctx, inTar, outStream, quiet) |
| 27 | 35 |
} |
| ... | ... |
@@ -32,9 +32,9 @@ type imageBackend interface {
|
| 32 | 32 |
} |
| 33 | 33 |
|
| 34 | 34 |
type importExportBackend interface {
|
| 35 |
- LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error |
|
| 35 |
+ LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error |
|
| 36 | 36 |
ImportImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, msg string, layerReader io.Reader, changes []string) (dockerimage.ID, error) |
| 37 |
- ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error |
|
| 37 |
+ ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error |
|
| 38 | 38 |
} |
| 39 | 39 |
|
| 40 | 40 |
type registryBackend interface {
|
| ... | ... |
@@ -235,6 +235,7 @@ func (ir *imageRouter) getImagesGet(ctx context.Context, w http.ResponseWriter, |
| 235 | 235 |
|
| 236 | 236 |
output := ioutils.NewWriteFlusher(w) |
| 237 | 237 |
defer output.Close() |
| 238 |
+ |
|
| 238 | 239 |
var names []string |
| 239 | 240 |
if name, ok := vars["name"]; ok {
|
| 240 | 241 |
names = []string{name}
|
| ... | ... |
@@ -242,27 +243,28 @@ func (ir *imageRouter) getImagesGet(ctx context.Context, w http.ResponseWriter, |
| 242 | 242 |
names = r.Form["names"] |
| 243 | 243 |
} |
| 244 | 244 |
|
| 245 |
- var platform *ocispec.Platform |
|
| 245 |
+ var platformList []ocispec.Platform |
|
| 246 |
+ // platform param was introduce in API version 1.48 |
|
| 246 | 247 |
if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.48") {
|
| 247 |
- if formPlatforms := r.Form["platform"]; len(formPlatforms) > 1 {
|
|
| 248 |
- // TODO(thaJeztah): remove once we support multiple platforms: see https://github.com/moby/moby/issues/48759 |
|
| 249 |
- return errdefs.InvalidParameter(errors.New("multiple platform parameters not supported"))
|
|
| 248 |
+ var err error |
|
| 249 |
+ formPlatforms := r.Form["platform"] |
|
| 250 |
+ // multi-platform params were introduced in API version 1.51 |
|
| 251 |
+ if versions.LessThan(httputils.VersionFromContext(ctx), "1.51") && len(formPlatforms) > 1 {
|
|
| 252 |
+ return errdefs.InvalidParameter(errors.New("multiple platform parameters are not supported in this API version; use API version 1.51 or later."))
|
|
| 250 | 253 |
} |
| 251 |
- if formPlatform := r.Form.Get("platform"); formPlatform != "" {
|
|
| 252 |
- p, err := httputils.DecodePlatform(formPlatform) |
|
| 253 |
- if err != nil {
|
|
| 254 |
- return err |
|
| 255 |
- } |
|
| 256 |
- platform = p |
|
| 254 |
+ platformList, err = httputils.DecodePlatforms(formPlatforms) |
|
| 255 |
+ if err != nil {
|
|
| 256 |
+ return err |
|
| 257 | 257 |
} |
| 258 | 258 |
} |
| 259 | 259 |
|
| 260 |
- if err := ir.backend.ExportImage(ctx, names, platform, output); err != nil {
|
|
| 260 |
+ if err := ir.backend.ExportImage(ctx, names, platformList, output); err != nil {
|
|
| 261 | 261 |
if !output.Flushed() {
|
| 262 | 262 |
return err |
| 263 | 263 |
} |
| 264 | 264 |
_, _ = output.Write(streamformatter.FormatError(err)) |
| 265 | 265 |
} |
| 266 |
+ |
|
| 266 | 267 |
return nil |
| 267 | 268 |
} |
| 268 | 269 |
|
| ... | ... |
@@ -271,18 +273,18 @@ func (ir *imageRouter) postImagesLoad(ctx context.Context, w http.ResponseWriter |
| 271 | 271 |
return err |
| 272 | 272 |
} |
| 273 | 273 |
|
| 274 |
- var platform *ocispec.Platform |
|
| 274 |
+ var platformList []ocispec.Platform |
|
| 275 |
+ // platform param was introduce in API version 1.48 |
|
| 275 | 276 |
if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.48") {
|
| 276 |
- if formPlatforms := r.Form["platform"]; len(formPlatforms) > 1 {
|
|
| 277 |
- // TODO(thaJeztah): remove once we support multiple platforms: see https://github.com/moby/moby/issues/48759 |
|
| 278 |
- return errdefs.InvalidParameter(errors.New("multiple platform parameters not supported"))
|
|
| 277 |
+ var err error |
|
| 278 |
+ formPlatforms := r.Form["platform"] |
|
| 279 |
+ // multi-platform params were introduced in API version 1.51 |
|
| 280 |
+ if versions.LessThan(httputils.VersionFromContext(ctx), "1.51") && len(formPlatforms) > 1 {
|
|
| 281 |
+ return errdefs.InvalidParameter(errors.New("multiple platform parameters are not supported in this API version; use API version 1.51 or later."))
|
|
| 279 | 282 |
} |
| 280 |
- if formPlatform := r.Form.Get("platform"); formPlatform != "" {
|
|
| 281 |
- p, err := httputils.DecodePlatform(formPlatform) |
|
| 282 |
- if err != nil {
|
|
| 283 |
- return err |
|
| 284 |
- } |
|
| 285 |
- platform = p |
|
| 283 |
+ platformList, err = httputils.DecodePlatforms(formPlatforms) |
|
| 284 |
+ if err != nil {
|
|
| 285 |
+ return err |
|
| 286 | 286 |
} |
| 287 | 287 |
} |
| 288 | 288 |
quiet := httputils.BoolValueOrDefault(r, "quiet", true) |
| ... | ... |
@@ -291,7 +293,8 @@ func (ir *imageRouter) postImagesLoad(ctx context.Context, w http.ResponseWriter |
| 291 | 291 |
|
| 292 | 292 |
output := ioutils.NewWriteFlusher(w) |
| 293 | 293 |
defer output.Close() |
| 294 |
- if err := ir.backend.LoadImage(ctx, r.Body, platform, output, quiet); err != nil {
|
|
| 294 |
+ |
|
| 295 |
+ if err := ir.backend.LoadImage(ctx, r.Body, platformList, output, quiet); err != nil {
|
|
| 295 | 296 |
_, _ = output.Write(streamformatter.FormatError(err)) |
| 296 | 297 |
} |
| 297 | 298 |
return nil |
| ... | ... |
@@ -13,7 +13,6 @@ import ( |
| 13 | 13 |
"testing" |
| 14 | 14 |
"time" |
| 15 | 15 |
|
| 16 |
- cerrdefs "github.com/containerd/errdefs" |
|
| 17 | 16 |
"github.com/cpuguy83/tar2go" |
| 18 | 17 |
"github.com/docker/docker/integration/internal/build" |
| 19 | 18 |
"github.com/docker/docker/integration/internal/container" |
| ... | ... |
@@ -23,6 +22,7 @@ import ( |
| 23 | 23 |
"github.com/docker/docker/testutil/fakecontext" |
| 24 | 24 |
"github.com/moby/go-archive/compression" |
| 25 | 25 |
containertypes "github.com/moby/moby/api/types/container" |
| 26 |
+ "github.com/moby/moby/api/types/image" |
|
| 26 | 27 |
"github.com/moby/moby/api/types/versions" |
| 27 | 28 |
"github.com/moby/moby/client" |
| 28 | 29 |
"github.com/opencontainers/go-digest" |
| ... | ... |
@@ -213,23 +213,167 @@ func TestSaveOCI(t *testing.T) {
|
| 213 | 213 |
} |
| 214 | 214 |
} |
| 215 | 215 |
|
| 216 |
-// TODO(thaJeztah): this test currently only checks invalid cases; update this test to use a table-test and test both valid and invalid platform options. |
|
| 217 |
-func TestSavePlatform(t *testing.T) {
|
|
| 218 |
- ctx := setupTest(t) |
|
| 216 |
+func TestSaveAndLoadPlatform(t *testing.T) {
|
|
| 217 |
+ skip.If(t, testEnv.DaemonInfo.OSType == "windows", "The test image is a Linux image") |
|
| 219 | 218 |
|
| 220 |
- t.Parallel() |
|
| 219 |
+ ctx := setupTest(t) |
|
| 221 | 220 |
apiClient := testEnv.APIClient() |
| 222 | 221 |
|
| 223 |
- const repoName = "busybox:latest" |
|
| 224 |
- _, err := apiClient.ImageInspect(ctx, repoName) |
|
| 225 |
- assert.NilError(t, err) |
|
| 222 |
+ const repoName = "alpine:latest" |
|
| 223 |
+ |
|
| 224 |
+ type testCase struct {
|
|
| 225 |
+ testName string |
|
| 226 |
+ containerdStoreOnly bool |
|
| 227 |
+ pullPlatforms []string |
|
| 228 |
+ savePlatforms []ocispec.Platform |
|
| 229 |
+ loadPlatforms []ocispec.Platform |
|
| 230 |
+ expectedSavedPlatforms []ocispec.Platform |
|
| 231 |
+ expectedLoadedPlatforms []ocispec.Platform // expected platforms to be saved, if empty, all pulled platforms are expected to be saved |
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ testCases := []testCase{
|
|
| 235 |
+ {
|
|
| 236 |
+ testName: "With no platforms specified", |
|
| 237 |
+ containerdStoreOnly: true, |
|
| 238 |
+ pullPlatforms: []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
|
|
| 239 |
+ savePlatforms: nil, |
|
| 240 |
+ loadPlatforms: nil, |
|
| 241 |
+ expectedSavedPlatforms: []ocispec.Platform{
|
|
| 242 |
+ {OS: "linux", Architecture: "amd64"},
|
|
| 243 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 244 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 245 |
+ }, |
|
| 246 |
+ expectedLoadedPlatforms: []ocispec.Platform{
|
|
| 247 |
+ {OS: "linux", Architecture: "amd64"},
|
|
| 248 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 249 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 250 |
+ }, |
|
| 251 |
+ }, |
|
| 252 |
+ {
|
|
| 253 |
+ testName: "With single pulled platform", |
|
| 254 |
+ pullPlatforms: []string{"linux/amd64"},
|
|
| 255 |
+ savePlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 256 |
+ loadPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 257 |
+ expectedSavedPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 258 |
+ expectedLoadedPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 259 |
+ }, |
|
| 260 |
+ {
|
|
| 261 |
+ testName: "With single platform save and load", |
|
| 262 |
+ containerdStoreOnly: true, |
|
| 263 |
+ pullPlatforms: []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
|
|
| 264 |
+ savePlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 265 |
+ loadPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 266 |
+ expectedSavedPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 267 |
+ expectedLoadedPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
|
|
| 268 |
+ }, |
|
| 269 |
+ {
|
|
| 270 |
+ testName: "With multiple platforms save and load", |
|
| 271 |
+ containerdStoreOnly: true, |
|
| 272 |
+ pullPlatforms: []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
|
|
| 273 |
+ savePlatforms: []ocispec.Platform{
|
|
| 274 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 275 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 276 |
+ }, |
|
| 277 |
+ loadPlatforms: []ocispec.Platform{
|
|
| 278 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 279 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 280 |
+ }, |
|
| 281 |
+ expectedSavedPlatforms: []ocispec.Platform{
|
|
| 282 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 283 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 284 |
+ }, |
|
| 285 |
+ expectedLoadedPlatforms: []ocispec.Platform{
|
|
| 286 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 287 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 288 |
+ }, |
|
| 289 |
+ }, |
|
| 290 |
+ {
|
|
| 291 |
+ testName: "With mixed platform save and load", |
|
| 292 |
+ containerdStoreOnly: true, |
|
| 293 |
+ pullPlatforms: []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
|
|
| 294 |
+ savePlatforms: []ocispec.Platform{
|
|
| 295 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 296 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 297 |
+ }, |
|
| 298 |
+ loadPlatforms: []ocispec.Platform{
|
|
| 299 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 300 |
+ }, |
|
| 301 |
+ expectedSavedPlatforms: []ocispec.Platform{
|
|
| 302 |
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
|
|
| 303 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 304 |
+ }, |
|
| 305 |
+ expectedLoadedPlatforms: []ocispec.Platform{
|
|
| 306 |
+ {OS: "linux", Architecture: "riscv64"},
|
|
| 307 |
+ }, |
|
| 308 |
+ }, |
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 311 |
+ for _, tc := range testCases {
|
|
| 312 |
+ if tc.containerdStoreOnly && !testEnv.UsingSnapshotter() {
|
|
| 313 |
+ continue |
|
| 314 |
+ } |
|
| 315 |
+ t.Run(tc.testName, func(t *testing.T) {
|
|
| 316 |
+ // pull the image |
|
| 317 |
+ for _, p := range tc.pullPlatforms {
|
|
| 318 |
+ resp, err := apiClient.ImagePull(ctx, repoName, image.PullOptions{Platform: p})
|
|
| 319 |
+ assert.NilError(t, err) |
|
| 320 |
+ _, err = io.ReadAll(resp) |
|
| 321 |
+ assert.NilError(t, err) |
|
| 322 |
+ resp.Close() |
|
| 323 |
+ } |
|
| 324 |
+ |
|
| 325 |
+ // export the image |
|
| 326 |
+ rdr, err := apiClient.ImageSave(ctx, []string{repoName}, client.ImageSaveWithPlatforms(tc.savePlatforms...))
|
|
| 327 |
+ assert.NilError(t, err) |
|
| 328 |
+ |
|
| 329 |
+ // remove the pulled image |
|
| 330 |
+ _, err = apiClient.ImageRemove(ctx, repoName, image.RemoveOptions{})
|
|
| 331 |
+ assert.NilError(t, err) |
|
| 332 |
+ |
|
| 333 |
+ // load the full exported image (all platforms in it) |
|
| 334 |
+ _, err = apiClient.ImageLoad(ctx, rdr) |
|
| 335 |
+ assert.NilError(t, err) |
|
| 336 |
+ rdr.Close() |
|
| 337 |
+ |
|
| 338 |
+ // verify the loaded image has all the expected saved platforms |
|
| 339 |
+ for _, p := range tc.expectedSavedPlatforms {
|
|
| 340 |
+ inspectResponse, err := apiClient.ImageInspect(ctx, repoName, client.ImageInspectWithPlatform(&p)) |
|
| 341 |
+ assert.NilError(t, err) |
|
| 342 |
+ assert.Check(t, is.Equal(inspectResponse.Os, p.OS)) |
|
| 343 |
+ assert.Check(t, is.Equal(inspectResponse.Architecture, p.Architecture)) |
|
| 344 |
+ } |
|
| 345 |
+ |
|
| 346 |
+ // pull the image again (start fresh) |
|
| 347 |
+ for _, p := range tc.pullPlatforms {
|
|
| 348 |
+ resp, err := apiClient.ImagePull(ctx, repoName, image.PullOptions{Platform: p})
|
|
| 349 |
+ assert.NilError(t, err) |
|
| 350 |
+ _, err = io.ReadAll(resp) |
|
| 351 |
+ assert.NilError(t, err) |
|
| 352 |
+ resp.Close() |
|
| 353 |
+ } |
|
| 226 | 354 |
|
| 227 |
- _, err = apiClient.ImageSave(ctx, []string{repoName}, client.ImageSaveWithPlatforms(
|
|
| 228 |
- ocispec.Platform{Architecture: "amd64", OS: "linux"},
|
|
| 229 |
- ocispec.Platform{Architecture: "arm64", OS: "linux", Variant: "v8"},
|
|
| 230 |
- )) |
|
| 231 |
- assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument)) |
|
| 232 |
- assert.Check(t, is.Error(err, "Error response from daemon: multiple platform parameters not supported")) |
|
| 355 |
+ // export the image |
|
| 356 |
+ rdr, err = apiClient.ImageSave(ctx, []string{repoName}, client.ImageSaveWithPlatforms(tc.savePlatforms...))
|
|
| 357 |
+ assert.NilError(t, err) |
|
| 358 |
+ |
|
| 359 |
+ // remove the pulled image |
|
| 360 |
+ _, err = apiClient.ImageRemove(ctx, repoName, image.RemoveOptions{})
|
|
| 361 |
+ assert.NilError(t, err) |
|
| 362 |
+ |
|
| 363 |
+ // load the exported image on the specified platforms only |
|
| 364 |
+ _, err = apiClient.ImageLoad(ctx, rdr, client.ImageLoadWithPlatforms(tc.loadPlatforms...)) |
|
| 365 |
+ assert.NilError(t, err) |
|
| 366 |
+ rdr.Close() |
|
| 367 |
+ |
|
| 368 |
+ // verify the image was loaded for the specified platforms |
|
| 369 |
+ for _, p := range tc.expectedLoadedPlatforms {
|
|
| 370 |
+ inspectResponse, err := apiClient.ImageInspect(ctx, repoName, client.ImageInspectWithPlatform(&p)) |
|
| 371 |
+ assert.NilError(t, err) |
|
| 372 |
+ assert.Check(t, is.Equal(inspectResponse.Os, p.OS)) |
|
| 373 |
+ assert.Check(t, is.Equal(inspectResponse.Architecture, p.Architecture)) |
|
| 374 |
+ } |
|
| 375 |
+ }) |
|
| 376 |
+ } |
|
| 233 | 377 |
} |
| 234 | 378 |
|
| 235 | 379 |
func TestSaveRepoWithMultipleImages(t *testing.T) {
|
| ... | ... |
@@ -10443,7 +10443,10 @@ paths: |
| 10443 | 10443 |
type: "string" |
| 10444 | 10444 |
required: true |
| 10445 | 10445 |
- name: "platform" |
| 10446 |
- type: "string" |
|
| 10446 |
+ type: "array" |
|
| 10447 |
+ items: |
|
| 10448 |
+ type: "string" |
|
| 10449 |
+ collectionFormat: "multi" |
|
| 10447 | 10450 |
in: "query" |
| 10448 | 10451 |
description: | |
| 10449 | 10452 |
JSON encoded OCI platform describing a platform which will be used |
| ... | ... |
@@ -10488,13 +10491,16 @@ paths: |
| 10488 | 10488 |
items: |
| 10489 | 10489 |
type: "string" |
| 10490 | 10490 |
- name: "platform" |
| 10491 |
- type: "string" |
|
| 10491 |
+ type: "array" |
|
| 10492 |
+ items: |
|
| 10493 |
+ type: "string" |
|
| 10494 |
+ collectionFormat: "multi" |
|
| 10492 | 10495 |
in: "query" |
| 10493 | 10496 |
description: | |
| 10494 |
- JSON encoded OCI platform describing a platform which will be used |
|
| 10495 |
- to select a platform-specific image to be saved if the image is |
|
| 10496 |
- multi-platform. |
|
| 10497 |
- If not provided, the full multi-platform image will be saved. |
|
| 10497 |
+ JSON encoded OCI platform(s) which will be used to select the |
|
| 10498 |
+ platform-specific image(s) to be saved if the image is |
|
| 10499 |
+ multi-platform. If not provided, the full multi-platform image |
|
| 10500 |
+ will be saved. |
|
| 10498 | 10501 |
|
| 10499 | 10502 |
Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
|
| 10500 | 10503 |
tags: ["Image"] |
| ... | ... |
@@ -10530,13 +10536,16 @@ paths: |
| 10530 | 10530 |
type: "boolean" |
| 10531 | 10531 |
default: false |
| 10532 | 10532 |
- name: "platform" |
| 10533 |
- type: "string" |
|
| 10533 |
+ type: "array" |
|
| 10534 |
+ items: |
|
| 10535 |
+ type: "string" |
|
| 10536 |
+ collectionFormat: "multi" |
|
| 10534 | 10537 |
in: "query" |
| 10535 | 10538 |
description: | |
| 10536 |
- JSON encoded OCI platform describing a platform which will be used |
|
| 10537 |
- to select a platform-specific image to be load if the image is |
|
| 10538 |
- multi-platform. |
|
| 10539 |
- If not provided, the full multi-platform image will be loaded. |
|
| 10539 |
+ JSON encoded OCI platform(s) which will be used to select the |
|
| 10540 |
+ platform-specific image(s) to load if the image is |
|
| 10541 |
+ multi-platform. If not provided, the full multi-platform image |
|
| 10542 |
+ will be loaded. |
|
| 10540 | 10543 |
|
| 10541 | 10544 |
Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
|
| 10542 | 10545 |
tags: ["Image"] |