Add a OCI platform fields as parameters to the `POST /images/{id}/push`
that allow to specify a specific-platform manifest to be pushed instead
of the whole image index.
When no platform was requested and pushing whole index failed, fallback
to pushing a platform-specific manifest with a best candidate (if it's
possible to choose one).
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
| ... | ... |
@@ -1,12 +1,17 @@ |
| 1 | 1 |
package httputils // import "github.com/docker/docker/api/server/httputils" |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 |
+ "encoding/json" |
|
| 4 | 5 |
"fmt" |
| 5 | 6 |
"net/http" |
| 6 | 7 |
"strconv" |
| 7 | 8 |
"strings" |
| 8 | 9 |
|
| 9 | 10 |
"github.com/distribution/reference" |
| 11 |
+ "github.com/docker/docker/errdefs" |
|
| 12 |
+ "github.com/pkg/errors" |
|
| 13 |
+ |
|
| 14 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 10 | 15 |
) |
| 11 | 16 |
|
| 12 | 17 |
// BoolValue transforms a form value in different formats into a boolean type. |
| ... | ... |
@@ -109,3 +114,24 @@ func ArchiveFormValues(r *http.Request, vars map[string]string) (ArchiveOptions, |
| 109 | 109 |
} |
| 110 | 110 |
return ArchiveOptions{name, path}, nil
|
| 111 | 111 |
} |
| 112 |
+ |
|
| 113 |
+// DecodePlatform decodes the OCI platform JSON string into a Platform struct. |
|
| 114 |
+func DecodePlatform(platformJSON string) (*ocispec.Platform, error) {
|
|
| 115 |
+ var p ocispec.Platform |
|
| 116 |
+ |
|
| 117 |
+ if err := json.Unmarshal([]byte(platformJSON), &p); err != nil {
|
|
| 118 |
+ return nil, errdefs.InvalidParameter(errors.Wrap(err, "failed to parse platform")) |
|
| 119 |
+ } |
|
| 120 |
+ |
|
| 121 |
+ hasAnyOptional := (p.Variant != "" || p.OSVersion != "" || len(p.OSFeatures) > 0) |
|
| 122 |
+ |
|
| 123 |
+ if p.OS == "" && p.Architecture == "" && hasAnyOptional {
|
|
| 124 |
+ return nil, errdefs.InvalidParameter(errors.New("optional platform fields provided, but OS and Architecture are missing"))
|
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ if p.OS == "" || p.Architecture == "" {
|
|
| 128 |
+ return nil, errdefs.InvalidParameter(errors.New("both OS and Architecture must be provided"))
|
|
| 129 |
+ } |
|
| 130 |
+ |
|
| 131 |
+ return &p, nil |
|
| 132 |
+} |
| ... | ... |
@@ -1,9 +1,16 @@ |
| 1 | 1 |
package httputils // import "github.com/docker/docker/api/server/httputils" |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 |
+ "encoding/json" |
|
| 4 | 5 |
"net/http" |
| 5 | 6 |
"net/url" |
| 6 | 7 |
"testing" |
| 8 |
+ |
|
| 9 |
+ "github.com/containerd/containerd/platforms" |
|
| 10 |
+ "github.com/docker/docker/errdefs" |
|
| 11 |
+ |
|
| 12 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 13 |
+ "gotest.tools/v3/assert" |
|
| 7 | 14 |
) |
| 8 | 15 |
|
| 9 | 16 |
func TestBoolValue(t *testing.T) {
|
| ... | ... |
@@ -103,3 +110,23 @@ func TestInt64ValueOrDefaultWithError(t *testing.T) {
|
| 103 | 103 |
t.Fatal("Expected an error.")
|
| 104 | 104 |
} |
| 105 | 105 |
} |
| 106 |
+ |
|
| 107 |
+func TestParsePlatformInvalid(t *testing.T) {
|
|
| 108 |
+ for _, tc := range []ocispec.Platform{
|
|
| 109 |
+ {
|
|
| 110 |
+ OSVersion: "1.2.3", |
|
| 111 |
+ OSFeatures: []string{"a", "b"},
|
|
| 112 |
+ }, |
|
| 113 |
+ {OSVersion: "12.0"},
|
|
| 114 |
+ {OS: "linux"},
|
|
| 115 |
+ {Architecture: "amd64"},
|
|
| 116 |
+ } {
|
|
| 117 |
+ t.Run(platforms.Format(tc), func(t *testing.T) {
|
|
| 118 |
+ js, err := json.Marshal(tc) |
|
| 119 |
+ assert.NilError(t, err) |
|
| 120 |
+ |
|
| 121 |
+ _, err = DecodePlatform(string(js)) |
|
| 122 |
+ assert.Check(t, errdefs.IsInvalidParameter(err)) |
|
| 123 |
+ }) |
|
| 124 |
+ } |
|
| 125 |
+} |
| ... | ... |
@@ -39,7 +39,7 @@ type importExportBackend interface {
|
| 39 | 39 |
|
| 40 | 40 |
type registryBackend interface {
|
| 41 | 41 |
PullImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error |
| 42 |
- PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error |
|
| 42 |
+ PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error |
|
| 43 | 43 |
} |
| 44 | 44 |
|
| 45 | 45 |
type Searcher interface {
|
| ... | ... |
@@ -205,7 +205,21 @@ func (ir *imageRouter) postImagesPush(ctx context.Context, w http.ResponseWriter |
| 205 | 205 |
ref = r |
| 206 | 206 |
} |
| 207 | 207 |
|
| 208 |
- if err := ir.backend.PushImage(ctx, ref, metaHeaders, authConfig, output); err != nil {
|
|
| 208 |
+ var platform *ocispec.Platform |
|
| 209 |
+ if formPlatform := r.Form.Get("platform"); formPlatform != "" {
|
|
| 210 |
+ if versions.LessThan(httputils.VersionFromContext(ctx), "1.46") {
|
|
| 211 |
+ return errdefs.InvalidParameter(errors.New("selecting platform is not supported in API version < 1.46"))
|
|
| 212 |
+ } |
|
| 213 |
+ |
|
| 214 |
+ p, err := httputils.DecodePlatform(formPlatform) |
|
| 215 |
+ if err != nil {
|
|
| 216 |
+ return err |
|
| 217 |
+ } |
|
| 218 |
+ |
|
| 219 |
+ platform = p |
|
| 220 |
+ } |
|
| 221 |
+ |
|
| 222 |
+ if err := ir.backend.PushImage(ctx, ref, platform, metaHeaders, authConfig, output); err != nil {
|
|
| 209 | 223 |
if !output.Flushed() {
|
| 210 | 224 |
return err |
| 211 | 225 |
} |
| ... | ... |
@@ -8752,6 +8752,11 @@ paths: |
| 8752 | 8752 |
details. |
| 8753 | 8753 |
type: "string" |
| 8754 | 8754 |
required: true |
| 8755 |
+ - name: "platform" |
|
| 8756 |
+ in: "query" |
|
| 8757 |
+ description: "Select a platform-specific manifest to be pushed. OCI platform (JSON encoded)" |
|
| 8758 |
+ type: "string" |
|
| 8759 |
+ x-nullable: true |
|
| 8755 | 8760 |
tags: ["Image"] |
| 8756 | 8761 |
/images/{name}/tag:
|
| 8757 | 8762 |
post: |
| ... | ... |
@@ -4,6 +4,7 @@ import ( |
| 4 | 4 |
"context" |
| 5 | 5 |
|
| 6 | 6 |
"github.com/docker/docker/api/types/filters" |
| 7 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 7 | 8 |
) |
| 8 | 9 |
|
| 9 | 10 |
// ImportOptions holds information to import images from the client host. |
| ... | ... |
@@ -36,7 +37,23 @@ type PullOptions struct {
|
| 36 | 36 |
} |
| 37 | 37 |
|
| 38 | 38 |
// PushOptions holds information to push images. |
| 39 |
-type PushOptions PullOptions |
|
| 39 |
+type PushOptions struct {
|
|
| 40 |
+ All bool |
|
| 41 |
+ RegistryAuth string // RegistryAuth is the base64 encoded credentials for the registry |
|
| 42 |
+ |
|
| 43 |
+ // PrivilegeFunc is a function that clients can supply to retry operations |
|
| 44 |
+ // after getting an authorization error. This function returns the registry |
|
| 45 |
+ // authentication header value in base64 encoded format, or an error if the |
|
| 46 |
+ // privilege request fails. |
|
| 47 |
+ // |
|
| 48 |
+ // Also see [github.com/docker/docker/api/types.RequestPrivilegeFunc]. |
|
| 49 |
+ PrivilegeFunc func(context.Context) (string, error) |
|
| 50 |
+ |
|
| 51 |
+ // Platform is an optional field that selects a specific platform to push |
|
| 52 |
+ // when the image is a multi-platform image. |
|
| 53 |
+ // Using this will only push a single platform-specific manifest. |
|
| 54 |
+ Platform *ocispec.Platform `json:",omitempty"` |
|
| 55 |
+} |
|
| 40 | 56 |
|
| 41 | 57 |
// ListOptions holds parameters to list images with. |
| 42 | 58 |
type ListOptions struct {
|
| ... | ... |
@@ -2,7 +2,9 @@ package client // import "github.com/docker/docker/client" |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 | 4 |
"context" |
| 5 |
+ "encoding/json" |
|
| 5 | 6 |
"errors" |
| 7 |
+ "fmt" |
|
| 6 | 8 |
"io" |
| 7 | 9 |
"net/http" |
| 8 | 10 |
"net/url" |
| ... | ... |
@@ -36,6 +38,20 @@ func (cli *Client) ImagePush(ctx context.Context, image string, options image.Pu |
| 36 | 36 |
} |
| 37 | 37 |
} |
| 38 | 38 |
|
| 39 |
+ if options.Platform != nil {
|
|
| 40 |
+ if err := cli.NewVersionError(ctx, "1.46", "platform"); err != nil {
|
|
| 41 |
+ return nil, err |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ p := *options.Platform |
|
| 45 |
+ pJson, err := json.Marshal(p) |
|
| 46 |
+ if err != nil {
|
|
| 47 |
+ return nil, fmt.Errorf("invalid platform: %v", err)
|
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ query.Set("platform", string(pJson))
|
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 39 | 53 |
resp, err := cli.tryImagePush(ctx, name, query, options.RegistryAuth) |
| 40 | 54 |
if errdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil {
|
| 41 | 55 |
newAuthHeader, privilegeErr := options.PrivilegeFunc(ctx) |
| ... | ... |
@@ -41,7 +41,7 @@ import ( |
| 41 | 41 |
// pointing to the new target repository. This will allow subsequent pushes |
| 42 | 42 |
// to perform cross-repo mounts of the shared content when pushing to a different |
| 43 | 43 |
// repository on the same registry. |
| 44 |
-func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) (retErr error) {
|
|
| 44 |
+func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) (retErr error) {
|
|
| 45 | 45 |
start := time.Now() |
| 46 | 46 |
defer func() {
|
| 47 | 47 |
if retErr == nil {
|
| ... | ... |
@@ -76,7 +76,7 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, |
| 76 | 76 |
continue |
| 77 | 77 |
} |
| 78 | 78 |
|
| 79 |
- if err := i.pushRef(ctx, named, metaHeaders, authConfig, out); err != nil {
|
|
| 79 |
+ if err := i.pushRef(ctx, named, platform, metaHeaders, authConfig, out); err != nil {
|
|
| 80 | 80 |
return err |
| 81 | 81 |
} |
| 82 | 82 |
} |
| ... | ... |
@@ -85,10 +85,10 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, |
| 85 | 85 |
} |
| 86 | 86 |
} |
| 87 | 87 |
|
| 88 |
- return i.pushRef(ctx, sourceRef, metaHeaders, authConfig, out) |
|
| 88 |
+ return i.pushRef(ctx, sourceRef, platform, metaHeaders, authConfig, out) |
|
| 89 | 89 |
} |
| 90 | 90 |
|
| 91 |
-func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, out progress.Output) (retErr error) {
|
|
| 91 |
+func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, out progress.Output) (retErr error) {
|
|
| 92 | 92 |
leasedCtx, release, err := i.client.WithLease(ctx) |
| 93 | 93 |
if err != nil {
|
| 94 | 94 |
return err |
| ... | ... |
@@ -104,12 +104,18 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m |
| 104 | 104 |
if cerrdefs.IsNotFound(err) {
|
| 105 | 105 |
return errdefs.NotFound(fmt.Errorf("tag does not exist: %s", reference.FamiliarString(targetRef)))
|
| 106 | 106 |
} |
| 107 |
- return errdefs.NotFound(err) |
|
| 107 |
+ return errdefs.System(err) |
|
| 108 | 108 |
} |
| 109 | 109 |
|
| 110 | 110 |
target := img.Target |
| 111 |
- store := i.content |
|
| 111 |
+ if platform != nil {
|
|
| 112 |
+ target, err = i.getPushDescriptor(ctx, img, platform) |
|
| 113 |
+ if err != nil {
|
|
| 114 |
+ return err |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 112 | 117 |
|
| 118 |
+ store := i.content |
|
| 113 | 119 |
resolver, tracker := i.newResolverFromAuthConfig(ctx, authConfig, targetRef) |
| 114 | 120 |
pp := pushProgress{Tracker: tracker}
|
| 115 | 121 |
jobsQueue := newJobs() |
| ... | ... |
@@ -121,7 +127,7 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m |
| 121 | 121 |
finishProgress() |
| 122 | 122 |
if retErr == nil {
|
| 123 | 123 |
if tagged, ok := targetRef.(reference.Tagged); ok {
|
| 124 |
- progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, img.Target.Size) |
|
| 124 |
+ progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, target.Size) |
|
| 125 | 125 |
} |
| 126 | 126 |
} |
| 127 | 127 |
}() |
| ... | ... |
@@ -164,16 +170,44 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m |
| 164 | 164 |
|
| 165 | 165 |
err = remotes.PushContent(ctx, pusher, target, store, limiter, platforms.All, handlerWrapper) |
| 166 | 166 |
if err != nil {
|
| 167 |
- if containerdimages.IsIndexType(target.MediaType) && cerrdefs.IsNotFound(err) {
|
|
| 167 |
+ // If push failed because of a missing content, no specific platform was requested |
|
| 168 |
+ // and the target is an index, select a platform-specific manifest to push instead. |
|
| 169 |
+ if cerrdefs.IsNotFound(err) && containerdimages.IsIndexType(target.MediaType) && platform == nil {
|
|
| 170 |
+ var newTarget ocispec.Descriptor |
|
| 171 |
+ newTarget, err = i.getPushDescriptor(ctx, img, nil) |
|
| 172 |
+ if err != nil {
|
|
| 173 |
+ return err |
|
| 174 |
+ } |
|
| 175 |
+ |
|
| 176 |
+ // Retry only if the new push candidate is different from the previous one. |
|
| 177 |
+ if newTarget.Digest != target.Digest {
|
|
| 178 |
+ target = newTarget |
|
| 179 |
+ pp.TurnNotStartedIntoUnavailable() |
|
| 180 |
+ err = remotes.PushContent(ctx, pusher, target, store, limiter, platforms.All, handlerWrapper) |
|
| 181 |
+ |
|
| 182 |
+ if err == nil {
|
|
| 183 |
+ progress.Aux(out, map[string]string{
|
|
| 184 |
+ "Stripped": "true", |
|
| 185 |
+ "Index": img.Target.Digest.String(), |
|
| 186 |
+ "Manifest": target.Digest.String(), |
|
| 187 |
+ }) |
|
| 188 |
+ } |
|
| 189 |
+ } |
|
| 190 |
+ } |
|
| 191 |
+ |
|
| 192 |
+ if err != nil {
|
|
| 193 |
+ if !cerrdefs.IsNotFound(err) {
|
|
| 194 |
+ return errdefs.System(err) |
|
| 195 |
+ } |
|
| 168 | 196 |
return errdefs.NotFound(fmt.Errorf( |
| 169 | 197 |
"missing content: %w\n"+ |
| 170 | 198 |
"Note: You're trying to push a manifest list/index which "+ |
| 171 | 199 |
"references multiple platform specific manifests, but not all of them are available locally "+ |
| 172 |
- "or available to the remote repository.\n"+ |
|
| 173 |
- "Make sure you have all the referenced content and try again.", |
|
| 200 |
+ "or available to the remote repository.\n\n"+ |
|
| 201 |
+ "Make sure you have all the referenced content and try again.\n"+ |
|
| 202 |
+ "You can also push only a single platform specific manifest directly by specifying the platform you want to push.", |
|
| 174 | 203 |
err)) |
| 175 | 204 |
} |
| 176 |
- return err |
|
| 177 | 205 |
} |
| 178 | 206 |
|
| 179 | 207 |
appendDistributionSourceLabel(ctx, realStore, targetRef, target) |
| ... | ... |
@@ -183,6 +217,97 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m |
| 183 | 183 |
return nil |
| 184 | 184 |
} |
| 185 | 185 |
|
| 186 |
+func (i *ImageService) getPushDescriptor(ctx context.Context, img containerdimages.Image, platform *ocispec.Platform) (ocispec.Descriptor, error) {
|
|
| 187 |
+ // Allow to override the host platform for testing purposes. |
|
| 188 |
+ hostPlatform := i.defaultPlatformOverride |
|
| 189 |
+ if hostPlatform == nil {
|
|
| 190 |
+ hostPlatform = platforms.Default() |
|
| 191 |
+ } |
|
| 192 |
+ |
|
| 193 |
+ pm := matchAllWithPreference(hostPlatform) |
|
| 194 |
+ if platform != nil {
|
|
| 195 |
+ pm = platforms.OnlyStrict(*platform) |
|
| 196 |
+ } |
|
| 197 |
+ |
|
| 198 |
+ anyMissing := false |
|
| 199 |
+ |
|
| 200 |
+ var bestMatchPlatform ocispec.Platform |
|
| 201 |
+ var bestMatch *ImageManifest |
|
| 202 |
+ var presentMatchingManifests []*ImageManifest |
|
| 203 |
+ err := i.walkReachableImageManifests(ctx, img, func(im *ImageManifest) error {
|
|
| 204 |
+ available, err := im.CheckContentAvailable(ctx) |
|
| 205 |
+ if err != nil {
|
|
| 206 |
+ return fmt.Errorf("failed to determine availability of image manifest %s: %w", im.Target().Digest, err)
|
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ if !available {
|
|
| 210 |
+ anyMissing = true |
|
| 211 |
+ return nil |
|
| 212 |
+ } |
|
| 213 |
+ |
|
| 214 |
+ if im.IsAttestation() {
|
|
| 215 |
+ return nil |
|
| 216 |
+ } |
|
| 217 |
+ |
|
| 218 |
+ imgPlatform, err := im.ImagePlatform(ctx) |
|
| 219 |
+ if err != nil {
|
|
| 220 |
+ return fmt.Errorf("failed to determine platform of image %s: %w", img.Name, err)
|
|
| 221 |
+ } |
|
| 222 |
+ |
|
| 223 |
+ if !pm.Match(imgPlatform) {
|
|
| 224 |
+ return nil |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 227 |
+ presentMatchingManifests = append(presentMatchingManifests, im) |
|
| 228 |
+ if bestMatch == nil || pm.Less(imgPlatform, bestMatchPlatform) {
|
|
| 229 |
+ bestMatchPlatform = imgPlatform |
|
| 230 |
+ bestMatch = im |
|
| 231 |
+ } |
|
| 232 |
+ |
|
| 233 |
+ return nil |
|
| 234 |
+ }) |
|
| 235 |
+ if err != nil {
|
|
| 236 |
+ return ocispec.Descriptor{}, err
|
|
| 237 |
+ } |
|
| 238 |
+ |
|
| 239 |
+ switch len(presentMatchingManifests) {
|
|
| 240 |
+ case 0: |
|
| 241 |
+ return ocispec.Descriptor{}, errdefs.NotFound(fmt.Errorf("no suitable image manifest found for platform %s", *platform))
|
|
| 242 |
+ case 1: |
|
| 243 |
+ // Only one manifest is available AND matching the requested platform. |
|
| 244 |
+ |
|
| 245 |
+ if platform != nil {
|
|
| 246 |
+ // Explicit platform was requested |
|
| 247 |
+ return presentMatchingManifests[0].Target(), nil |
|
| 248 |
+ } |
|
| 249 |
+ |
|
| 250 |
+ // No specific platform was requested, but only one manifest is available. |
|
| 251 |
+ if anyMissing {
|
|
| 252 |
+ return presentMatchingManifests[0].Target(), nil |
|
| 253 |
+ } |
|
| 254 |
+ |
|
| 255 |
+ // Index has only one manifest anyway, select the full index. |
|
| 256 |
+ return img.Target, nil |
|
| 257 |
+ default: |
|
| 258 |
+ if platform == nil {
|
|
| 259 |
+ if !anyMissing {
|
|
| 260 |
+ // No specific platform requested, and all manifests are available, select the full index. |
|
| 261 |
+ return img.Target, nil |
|
| 262 |
+ } |
|
| 263 |
+ |
|
| 264 |
+ // No specific platform requested and not all manifests are available. |
|
| 265 |
+ // Select the manifest that matches the host platform the best. |
|
| 266 |
+ if bestMatch != nil && hostPlatform.Match(bestMatchPlatform) {
|
|
| 267 |
+ return bestMatch.Target(), nil |
|
| 268 |
+ } |
|
| 269 |
+ |
|
| 270 |
+ return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple matching manifests found but no specific platform requested"))
|
|
| 271 |
+ } |
|
| 272 |
+ |
|
| 273 |
+ return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple manifests found for platform %s", *platform))
|
|
| 274 |
+ } |
|
| 275 |
+} |
|
| 276 |
+ |
|
| 186 | 277 |
func appendDistributionSourceLabel(ctx context.Context, realStore content.Store, targetRef reference.Named, target ocispec.Descriptor) {
|
| 187 | 278 |
appendSource, err := docker.AppendDistributionSourceLabel(realStore, targetRef.String()) |
| 188 | 279 |
if err != nil {
|
| 189 | 280 |
new file mode 100644 |
| ... | ... |
@@ -0,0 +1,288 @@ |
| 0 |
+// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: |
|
| 1 |
+//go:build go1.19 |
|
| 2 |
+ |
|
| 3 |
+package containerd |
|
| 4 |
+ |
|
| 5 |
+import ( |
|
| 6 |
+ "context" |
|
| 7 |
+ "fmt" |
|
| 8 |
+ "path/filepath" |
|
| 9 |
+ "testing" |
|
| 10 |
+ |
|
| 11 |
+ containerdimages "github.com/containerd/containerd/images" |
|
| 12 |
+ "github.com/containerd/containerd/namespaces" |
|
| 13 |
+ "github.com/containerd/containerd/platforms" |
|
| 14 |
+ "github.com/docker/docker/errdefs" |
|
| 15 |
+ "github.com/docker/docker/internal/testutils/specialimage" |
|
| 16 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 17 |
+ "golang.org/x/exp/slices" |
|
| 18 |
+ "gotest.tools/v3/assert" |
|
| 19 |
+ is "gotest.tools/v3/assert/cmp" |
|
| 20 |
+) |
|
| 21 |
+ |
|
| 22 |
+type pushTestCase struct {
|
|
| 23 |
+ name string |
|
| 24 |
+ indexPlatforms []platforms.Platform // all platforms supported by the image |
|
| 25 |
+ availablePlatforms []platforms.Platform // platforms available locally |
|
| 26 |
+ requestPlatform *platforms.Platform // platform requested by the client (not the platform selected for push!) |
|
| 27 |
+ check func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) |
|
| 28 |
+ daemonPlatform *platforms.Platform |
|
| 29 |
+} |
|
| 30 |
+ |
|
| 31 |
+func TestImagePushIndex(t *testing.T) {
|
|
| 32 |
+ ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name()) |
|
| 33 |
+ |
|
| 34 |
+ csDir := t.TempDir() |
|
| 35 |
+ store := &blobsDirContentStore{blobs: filepath.Join(csDir, "blobs/sha256")}
|
|
| 36 |
+ |
|
| 37 |
+ linuxAmd64 := platforms.MustParse("linux/amd64")
|
|
| 38 |
+ darwinArm64 := platforms.MustParse("darwin/arm64")
|
|
| 39 |
+ windowsAmd64 := platforms.MustParse("windows/amd64")
|
|
| 40 |
+ |
|
| 41 |
+ linuxArm64 := platforms.MustParse("linux/arm64")
|
|
| 42 |
+ linuxArmv5 := platforms.MustParse("linux/arm/v5")
|
|
| 43 |
+ linuxArmv7 := platforms.MustParse("linux/arm/v7")
|
|
| 44 |
+ |
|
| 45 |
+ // Image service will have the daemon host platform mocked to linux/amd64. |
|
| 46 |
+ // Unless test cases specify a different platform. |
|
| 47 |
+ defaultDaemonPlatform := linuxAmd64 |
|
| 48 |
+ |
|
| 49 |
+ for _, tc := range []pushTestCase{
|
|
| 50 |
+ // No explicit platform requested |
|
| 51 |
+ {
|
|
| 52 |
+ name: "none requested, all present", |
|
| 53 |
+ |
|
| 54 |
+ indexPlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 55 |
+ availablePlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 56 |
+ check: wholeIndexSelected, |
|
| 57 |
+ }, |
|
| 58 |
+ {
|
|
| 59 |
+ name: "none requested, one present", |
|
| 60 |
+ |
|
| 61 |
+ indexPlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 62 |
+ availablePlatforms: []platforms.Platform{linuxAmd64},
|
|
| 63 |
+ check: singleManifestSelected(linuxAmd64), |
|
| 64 |
+ }, |
|
| 65 |
+ {
|
|
| 66 |
+ name: "none requested, two present, daemon platform available", |
|
| 67 |
+ |
|
| 68 |
+ indexPlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 69 |
+ availablePlatforms: []platforms.Platform{linuxAmd64, darwinArm64},
|
|
| 70 |
+ check: singleManifestSelected(linuxAmd64), |
|
| 71 |
+ }, |
|
| 72 |
+ {
|
|
| 73 |
+ name: "none requested, two present, daemon platform NOT available", |
|
| 74 |
+ |
|
| 75 |
+ indexPlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 76 |
+ availablePlatforms: []platforms.Platform{darwinArm64, windowsAmd64},
|
|
| 77 |
+ check: multipleCandidates, |
|
| 78 |
+ }, |
|
| 79 |
+ |
|
| 80 |
+ // Specific platform requested |
|
| 81 |
+ {
|
|
| 82 |
+ name: "linux/amd64 requested, all present", |
|
| 83 |
+ |
|
| 84 |
+ indexPlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 85 |
+ availablePlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 86 |
+ requestPlatform: &linuxAmd64, |
|
| 87 |
+ check: singleManifestSelected(linuxAmd64), |
|
| 88 |
+ }, |
|
| 89 |
+ {
|
|
| 90 |
+ name: "linux/amd64 requested, but not present", |
|
| 91 |
+ |
|
| 92 |
+ indexPlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
|
|
| 93 |
+ availablePlatforms: []platforms.Platform{darwinArm64, windowsAmd64},
|
|
| 94 |
+ requestPlatform: &linuxAmd64, |
|
| 95 |
+ check: candidateNotFound, |
|
| 96 |
+ }, |
|
| 97 |
+ |
|
| 98 |
+ // Variant tests |
|
| 99 |
+ {
|
|
| 100 |
+ name: "linux/arm/v5 requested, but not in index", |
|
| 101 |
+ |
|
| 102 |
+ indexPlatforms: []platforms.Platform{linuxAmd64, linuxArmv7},
|
|
| 103 |
+ availablePlatforms: []platforms.Platform{linuxAmd64, linuxArmv7},
|
|
| 104 |
+ requestPlatform: &linuxArmv5, |
|
| 105 |
+ check: candidateNotFound, |
|
| 106 |
+ }, |
|
| 107 |
+ {
|
|
| 108 |
+ name: "linux/arm/v5 requested, but not available", |
|
| 109 |
+ |
|
| 110 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 111 |
+ availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv7},
|
|
| 112 |
+ requestPlatform: &linuxArmv5, |
|
| 113 |
+ check: candidateNotFound, |
|
| 114 |
+ }, |
|
| 115 |
+ {
|
|
| 116 |
+ name: "linux/arm/v7 requested, but not available", |
|
| 117 |
+ |
|
| 118 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 119 |
+ availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
|
|
| 120 |
+ requestPlatform: &linuxArmv7, |
|
| 121 |
+ check: candidateNotFound, |
|
| 122 |
+ }, |
|
| 123 |
+ {
|
|
| 124 |
+ name: "linux/arm/v7 requested on v7 daemon, but not available", |
|
| 125 |
+ |
|
| 126 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 127 |
+ availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
|
|
| 128 |
+ daemonPlatform: &linuxArmv7, |
|
| 129 |
+ requestPlatform: &linuxArmv7, |
|
| 130 |
+ check: candidateNotFound, |
|
| 131 |
+ }, |
|
| 132 |
+ {
|
|
| 133 |
+ name: "linux/arm/v7 requested on v5 daemon, all available", |
|
| 134 |
+ |
|
| 135 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 136 |
+ availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 137 |
+ daemonPlatform: &linuxArmv5, |
|
| 138 |
+ requestPlatform: &linuxArmv7, |
|
| 139 |
+ check: singleManifestSelected(linuxArmv7), |
|
| 140 |
+ }, |
|
| 141 |
+ {
|
|
| 142 |
+ name: "linux/arm/v5 requested on v7 daemon, all available", |
|
| 143 |
+ |
|
| 144 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 145 |
+ availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 146 |
+ daemonPlatform: &linuxArmv7, |
|
| 147 |
+ requestPlatform: &linuxArmv5, |
|
| 148 |
+ check: singleManifestSelected(linuxArmv5), |
|
| 149 |
+ }, |
|
| 150 |
+ {
|
|
| 151 |
+ name: "none requested on v5 daemon, arm64 not available", |
|
| 152 |
+ |
|
| 153 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 154 |
+ availablePlatforms: []platforms.Platform{linuxArmv7, linuxArmv5},
|
|
| 155 |
+ daemonPlatform: &linuxArmv5, |
|
| 156 |
+ requestPlatform: nil, |
|
| 157 |
+ check: singleManifestSelected(linuxArmv5), |
|
| 158 |
+ }, |
|
| 159 |
+ {
|
|
| 160 |
+ name: "none requested on v7 daemon, arm64 not available", |
|
| 161 |
+ |
|
| 162 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 163 |
+ availablePlatforms: []platforms.Platform{linuxArmv7, linuxArmv5},
|
|
| 164 |
+ daemonPlatform: &linuxArmv7, |
|
| 165 |
+ requestPlatform: nil, |
|
| 166 |
+ check: singleManifestSelected(linuxArmv7), |
|
| 167 |
+ }, |
|
| 168 |
+ {
|
|
| 169 |
+ name: "none requested on v7 daemon, v7 not available", |
|
| 170 |
+ |
|
| 171 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
|
|
| 172 |
+ availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
|
|
| 173 |
+ daemonPlatform: &linuxArmv7, |
|
| 174 |
+ requestPlatform: nil, |
|
| 175 |
+ check: singleManifestSelected(linuxArmv5), // Should it fail, because v5 can't be pushed? |
|
| 176 |
+ }, |
|
| 177 |
+ |
|
| 178 |
+ {
|
|
| 179 |
+ name: "none requested on v7 daemon, v5 in index but not v7, all present", |
|
| 180 |
+ |
|
| 181 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
|
|
| 182 |
+ availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
|
|
| 183 |
+ daemonPlatform: &linuxArmv7, |
|
| 184 |
+ requestPlatform: nil, |
|
| 185 |
+ check: wholeIndexSelected, |
|
| 186 |
+ }, |
|
| 187 |
+ {
|
|
| 188 |
+ name: "none requested on v7 daemon, v5 in index but not v7, v5 present", |
|
| 189 |
+ |
|
| 190 |
+ indexPlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
|
|
| 191 |
+ availablePlatforms: []platforms.Platform{linuxArmv5},
|
|
| 192 |
+ daemonPlatform: &linuxArmv7, |
|
| 193 |
+ requestPlatform: nil, |
|
| 194 |
+ check: singleManifestSelected(linuxArmv5), |
|
| 195 |
+ }, |
|
| 196 |
+ } {
|
|
| 197 |
+ t.Run(tc.name, func(t *testing.T) {
|
|
| 198 |
+ imgSvc := fakeImageService(t, ctx, store) |
|
| 199 |
+ // Mock the daemon platform. |
|
| 200 |
+ if tc.daemonPlatform != nil {
|
|
| 201 |
+ imgSvc.defaultPlatformOverride = platforms.Only(*tc.daemonPlatform) |
|
| 202 |
+ } else {
|
|
| 203 |
+ imgSvc.defaultPlatformOverride = platforms.Only(defaultDaemonPlatform) |
|
| 204 |
+ } |
|
| 205 |
+ |
|
| 206 |
+ idx, err := specialimage.MultiPlatform(csDir, "multiplatform:latest", tc.indexPlatforms) |
|
| 207 |
+ assert.NilError(t, err) |
|
| 208 |
+ |
|
| 209 |
+ imgs := imagesFromIndex(idx) |
|
| 210 |
+ assert.Assert(t, is.Len(imgs, 1)) |
|
| 211 |
+ |
|
| 212 |
+ img := imgs[0] |
|
| 213 |
+ _, err = imgSvc.images.Create(ctx, img) |
|
| 214 |
+ assert.NilError(t, err) |
|
| 215 |
+ |
|
| 216 |
+ for _, platform := range tc.indexPlatforms {
|
|
| 217 |
+ if slices.ContainsFunc(tc.availablePlatforms, platforms.OnlyStrict(platform).Match) {
|
|
| 218 |
+ continue |
|
| 219 |
+ } |
|
| 220 |
+ assert.NilError(t, deletePlatform(ctx, imgSvc, img, platform)) |
|
| 221 |
+ } |
|
| 222 |
+ |
|
| 223 |
+ desc, err := imgSvc.getPushDescriptor(ctx, img, tc.requestPlatform) |
|
| 224 |
+ |
|
| 225 |
+ tc.check(t, img, desc, err) |
|
| 226 |
+ }) |
|
| 227 |
+ } |
|
| 228 |
+} |
|
| 229 |
+ |
|
| 230 |
+func deletePlatform(ctx context.Context, imgSvc *ImageService, img containerdimages.Image, platform platforms.Platform) error {
|
|
| 231 |
+ var blobs []ocispec.Descriptor |
|
| 232 |
+ pm := platforms.OnlyStrict(platform) |
|
| 233 |
+ err := imgSvc.walkImageManifests(ctx, img, func(im *ImageManifest) error {
|
|
| 234 |
+ imPlatform, err := im.ImagePlatform(ctx) |
|
| 235 |
+ if err != nil {
|
|
| 236 |
+ return fmt.Errorf("failed to determine platform of image manifest %v: %w", im.Target(), err)
|
|
| 237 |
+ } |
|
| 238 |
+ |
|
| 239 |
+ if !pm.Match(imPlatform) {
|
|
| 240 |
+ return nil |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ return imgSvc.walkPresentChildren(ctx, im.Target(), func(ctx context.Context, d ocispec.Descriptor) error {
|
|
| 244 |
+ blobs = append(blobs, d) |
|
| 245 |
+ return nil |
|
| 246 |
+ }) |
|
| 247 |
+ }) |
|
| 248 |
+ if err != nil {
|
|
| 249 |
+ return fmt.Errorf("failed to walk image manifests: %w", err)
|
|
| 250 |
+ } |
|
| 251 |
+ |
|
| 252 |
+ for _, d := range blobs {
|
|
| 253 |
+ err := imgSvc.content.Delete(ctx, d.Digest) |
|
| 254 |
+ if err != nil {
|
|
| 255 |
+ return fmt.Errorf("failed to delete blob %v: %w", d.Digest, err)
|
|
| 256 |
+ } |
|
| 257 |
+ } |
|
| 258 |
+ |
|
| 259 |
+ return nil |
|
| 260 |
+} |
|
| 261 |
+ |
|
| 262 |
+// wholeIndexSelected asserts that the push descriptor candidate is for the whole index. |
|
| 263 |
+func wholeIndexSelected(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) {
|
|
| 264 |
+ assert.NilError(t, err) |
|
| 265 |
+ assert.Check(t, is.Equal(pushDescriptor.Digest, img.Target.Digest)) |
|
| 266 |
+} |
|
| 267 |
+ |
|
| 268 |
+// singleManifestSelected asserts that the push descriptor candidate is for a single platform-specific manifest. |
|
| 269 |
+func singleManifestSelected(platform ocispec.Platform) func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) {
|
|
| 270 |
+ pm := platforms.OnlyStrict(platform) |
|
| 271 |
+ return func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) {
|
|
| 272 |
+ assert.NilError(t, err) |
|
| 273 |
+ assert.Assert(t, is.Equal(pushDescriptor.MediaType, ocispec.MediaTypeImageManifest), "the push descriptor isn't for a manifest") |
|
| 274 |
+ assert.Assert(t, pushDescriptor.Platform != nil, "the push descriptor doesn't have a platform") |
|
| 275 |
+ assert.Assert(t, pm.Match(*pushDescriptor.Platform), "the push descriptor isn't for the selected platform") |
|
| 276 |
+ } |
|
| 277 |
+} |
|
| 278 |
+ |
|
| 279 |
+// candidateNotFound asserts that the no matching candidate was found. |
|
| 280 |
+func candidateNotFound(t *testing.T, _ containerdimages.Image, desc ocispec.Descriptor, err error) {
|
|
| 281 |
+ assert.Check(t, errdefs.IsNotFound(err), "expected NotFound error, got %v, candidate: %v", err, desc.Platform) |
|
| 282 |
+} |
|
| 283 |
+ |
|
| 284 |
+// multipleCandidates asserts that multiple matching candidates were found and no decision could be made. |
|
| 285 |
+func multipleCandidates(t *testing.T, _ containerdimages.Image, desc ocispec.Descriptor, err error) {
|
|
| 286 |
+ assert.Check(t, errdefs.IsConflict(err), "expected Conflict error, got %v, candidate: %v", err, desc.Platform) |
|
| 287 |
+} |
| ... | ... |
@@ -4,6 +4,7 @@ import ( |
| 4 | 4 |
"context" |
| 5 | 5 |
"errors" |
| 6 | 6 |
"sync" |
| 7 |
+ "sync/atomic" |
|
| 7 | 8 |
"time" |
| 8 | 9 |
|
| 9 | 10 |
"github.com/containerd/containerd/content" |
| ... | ... |
@@ -174,7 +175,13 @@ func (p pullProgress) UpdateProgress(ctx context.Context, ongoing *jobs, out pro |
| 174 | 174 |
} |
| 175 | 175 |
|
| 176 | 176 |
type pushProgress struct {
|
| 177 |
- Tracker docker.StatusTracker |
|
| 177 |
+ Tracker docker.StatusTracker |
|
| 178 |
+ notStartedWaitingAreUnavailable atomic.Bool |
|
| 179 |
+} |
|
| 180 |
+ |
|
| 181 |
+// TurnNotStartedIntoUnavailable will mark all not started layers as "Unavailable" instead of "Waiting". |
|
| 182 |
+func (p *pushProgress) TurnNotStartedIntoUnavailable() {
|
|
| 183 |
+ p.notStartedWaitingAreUnavailable.Store(true) |
|
| 178 | 184 |
} |
| 179 | 185 |
|
| 180 | 186 |
func (p *pushProgress) UpdateProgress(ctx context.Context, ongoing *jobs, out progress.Output, start time.Time) error {
|
| ... | ... |
@@ -183,7 +190,14 @@ func (p *pushProgress) UpdateProgress(ctx context.Context, ongoing *jobs, out pr |
| 183 | 183 |
id := stringid.TruncateID(j.Digest.Encoded()) |
| 184 | 184 |
|
| 185 | 185 |
status, err := p.Tracker.GetStatus(key) |
| 186 |
- if err != nil {
|
|
| 186 |
+ |
|
| 187 |
+ notStarted := (status.Total > 0 && status.Offset == 0) |
|
| 188 |
+ if err != nil || notStarted {
|
|
| 189 |
+ if p.notStartedWaitingAreUnavailable.Load() {
|
|
| 190 |
+ progress.Update(out, id, "Unavailable") |
|
| 191 |
+ ongoing.Remove(j) |
|
| 192 |
+ continue |
|
| 193 |
+ } |
|
| 187 | 194 |
if cerrdefs.IsNotFound(err) {
|
| 188 | 195 |
progress.Update(out, id, "Waiting") |
| 189 | 196 |
continue |
| ... | ... |
@@ -8,6 +8,7 @@ import ( |
| 8 | 8 |
"github.com/containerd/containerd" |
| 9 | 9 |
"github.com/containerd/containerd/content" |
| 10 | 10 |
"github.com/containerd/containerd/images" |
| 11 |
+ "github.com/containerd/containerd/platforms" |
|
| 11 | 12 |
"github.com/containerd/containerd/plugin" |
| 12 | 13 |
"github.com/containerd/containerd/remotes/docker" |
| 13 | 14 |
"github.com/containerd/containerd/snapshots" |
| ... | ... |
@@ -39,6 +40,9 @@ type ImageService struct {
|
| 39 | 39 |
pruneRunning atomic.Bool |
| 40 | 40 |
refCountMounter snapshotter.Mounter |
| 41 | 41 |
idMapping idtools.IdentityMapping |
| 42 |
+ |
|
| 43 |
+ // defaultPlatformOverride is used in tests to override the host platform. |
|
| 44 |
+ defaultPlatformOverride platforms.MatchComparer |
|
| 42 | 45 |
} |
| 43 | 46 |
|
| 44 | 47 |
type registryResolver interface {
|
| ... | ... |
@@ -28,7 +28,7 @@ type ImageService interface {
|
| 28 | 28 |
// Images |
| 29 | 29 |
|
| 30 | 30 |
PullImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error |
| 31 |
- PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error |
|
| 31 |
+ PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error |
|
| 32 | 32 |
CreateImage(ctx context.Context, config []byte, parent string, contentStoreDigest digest.Digest) (builder.Image, error) |
| 33 | 33 |
ImageDelete(ctx context.Context, imageRef string, force, prune bool) ([]imagetype.DeleteResponse, error) |
| 34 | 34 |
ExportImage(ctx context.Context, names []string, outStream io.Writer) error |
| ... | ... |
@@ -2,19 +2,30 @@ package images // import "github.com/docker/docker/daemon/images" |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 | 4 |
"context" |
| 5 |
+ "errors" |
|
| 5 | 6 |
"io" |
| 6 | 7 |
"time" |
| 7 | 8 |
|
| 8 | 9 |
"github.com/distribution/reference" |
| 9 | 10 |
"github.com/docker/distribution/manifest/schema2" |
| 11 |
+ "github.com/docker/docker/api/types/backend" |
|
| 10 | 12 |
"github.com/docker/docker/api/types/registry" |
| 11 | 13 |
"github.com/docker/docker/distribution" |
| 12 | 14 |
progressutils "github.com/docker/docker/distribution/utils" |
| 15 |
+ "github.com/docker/docker/errdefs" |
|
| 13 | 16 |
"github.com/docker/docker/pkg/progress" |
| 17 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 14 | 18 |
) |
| 15 | 19 |
|
| 16 | 20 |
// PushImage initiates a push operation on the repository named localName. |
| 17 |
-func (i *ImageService) PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error {
|
|
| 21 |
+func (i *ImageService) PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error {
|
|
| 22 |
+ if platform != nil {
|
|
| 23 |
+ // Check if the image is actually the platform we want to push. |
|
| 24 |
+ _, err := i.GetImage(ctx, ref.String(), backend.GetImageOpts{Platform: platform})
|
|
| 25 |
+ if err != nil {
|
|
| 26 |
+ return errdefs.InvalidParameter(errors.New("graphdriver backed image store doesn't support multiplatform images"))
|
|
| 27 |
+ } |
|
| 28 |
+ } |
|
| 18 | 29 |
start := time.Now() |
| 19 | 30 |
// Include a buffer so that slow client connections don't affect |
| 20 | 31 |
// transfer performance. |
| ... | ... |
@@ -26,8 +26,10 @@ keywords: "API, Docker, rcli, REST, documentation" |
| 26 | 26 |
`net.ipv4.config.IFNAME.log_martians=1`. In API versions up-to 1.46, top level |
| 27 | 27 |
`--sysctl` settings for `eth0` will be migrated to `DriverOpts` when possible. |
| 28 | 28 |
This automatic migration will be removed for API versions 1.47 and greater. |
| 29 |
- |
|
| 30 | 29 |
* `GET /containers/json` now returns the annotations of containers. |
| 30 |
+* `POST /images/{name}/push` now supports a `platform` parameter (JSON encoded
|
|
| 31 |
+ OCI Platform type) that allows selecting a specific platform manifest from |
|
| 32 |
+ the multi-platform image. |
|
| 31 | 33 |
|
| 32 | 34 |
## v1.45 API changes |
| 33 | 35 |
|
| ... | ... |
@@ -99,6 +99,7 @@ require ( |
| 99 | 99 |
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 |
| 100 | 100 |
go.opentelemetry.io/otel/sdk v1.21.0 |
| 101 | 101 |
go.opentelemetry.io/otel/trace v1.21.0 |
| 102 |
+ golang.org/x/exp v0.0.0-20231006140011-7918f672742d |
|
| 102 | 103 |
golang.org/x/mod v0.17.0 |
| 103 | 104 |
golang.org/x/net v0.23.0 |
| 104 | 105 |
golang.org/x/sync v0.5.0 |
| ... | ... |
@@ -216,7 +217,6 @@ require ( |
| 216 | 216 |
go.uber.org/multierr v1.8.0 // indirect |
| 217 | 217 |
go.uber.org/zap v1.21.0 // indirect |
| 218 | 218 |
golang.org/x/crypto v0.21.0 // indirect |
| 219 |
- golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect |
|
| 220 | 219 |
golang.org/x/oauth2 v0.11.0 // indirect |
| 221 | 220 |
golang.org/x/tools v0.16.0 // indirect |
| 222 | 221 |
google.golang.org/api v0.128.0 // indirect |