Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(publish): add OCI 1.0 fallback support for AWS ECR #11239

Merged
merged 7 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/compose/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
type publishOptions struct {
*ProjectOptions
resolveImageDigests bool
ociVersion string
}

func publishCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command {
Expand All @@ -44,6 +45,7 @@
}
flags := cmd.Flags()
flags.BoolVar(&opts.resolveImageDigests, "resolve-image-digests", false, "Pin image tags to digests.")
flags.StringVar(&opts.ociVersion, "oci-version", "", "OCI Image specification version (automatically determined by default)")
return cmd
}

Expand All @@ -55,5 +57,6 @@

return backend.Publish(ctx, project, repository, api.PublishOptions{
ResolveImageDigests: opts.resolveImageDigests,
OCIVersion: api.OCIVersion(opts.ociVersion),

Check warning on line 60 in cmd/compose/publish.go

View check run for this annotation

Codecov / codecov/patch

cmd/compose/publish.go#L60

Added line #L60 was not covered by tests
})
}
9 changes: 5 additions & 4 deletions docs/reference/compose_alpha_publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ Publish compose application

### Options

| Name | Type | Default | Description |
|:--------------------------|:-----|:--------|:--------------------------------|
| `--dry-run` | | | Execute command in dry run mode |
| `--resolve-image-digests` | | | Pin image tags to digests. |
| Name | Type | Default | Description |
|:--------------------------|:---------|:--------|:----------------------------------------------------------------------|
| `--dry-run` | | | Execute command in dry run mode |
| `--oci-version` | `string` | | OCI Image specification version (automatically determined by default) |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OCI artifact specification

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

really? So this is an non-secret plan to make all this think damn confusing as an "image" can now either be an image or an arbitrary artifact 🥸

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this to say "OCI Image/Artifact specification" - does that sound OK?

[also asked internally in Slack if anyone knows what the suggested nomenclature is 🤷🏻]

| `--resolve-image-digests` | | | Pin image tags to digests. |


<!---MARKER_GEN_END-->
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/docker_compose_alpha_publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ usage: docker compose alpha publish [OPTIONS] [REPOSITORY]
pname: docker compose alpha
plink: docker_compose_alpha.yaml
options:
- option: oci-version
value_type: string
description: |
OCI Image specification version (automatically determined by default)
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: resolve-image-digests
value_type: bool
default_value: "false"
Expand Down
183 changes: 183 additions & 0 deletions internal/ocipush/push.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
Copyright 2023 Docker Compose CLI authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package ocipush

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"time"

pusherrors "github.com/containerd/containerd/remotes/errors"
"github.com/distribution/reference"
"github.com/docker/buildx/util/imagetools"
"github.com/docker/compose/v2/pkg/api"
"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

// clientAuthStatusCodes are client (4xx) errors that are authentication
// related.
var clientAuthStatusCodes = []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusProxyAuthRequired,
}

type Pushable struct {
Descriptor v1.Descriptor
Data []byte
}

func DescriptorForComposeFile(path string, content []byte) v1.Descriptor {
return v1.Descriptor{
MediaType: "application/vnd.docker.compose.file+yaml",
Digest: digest.FromString(string(content)),
Size: int64(len(content)),
Annotations: map[string]string{
"com.docker.compose.version": api.ComposeVersion,
"com.docker.compose.file": filepath.Base(path),
},
}

Check warning on line 59 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L50-L59

Added lines #L50 - L59 were not covered by tests
}

func PushManifest(
ctx context.Context,
resolver *imagetools.Resolver,
named reference.Named,
layers []Pushable,
ociVersion api.OCIVersion,
) error {
// prepare to push the manifest by pushing the layers
layerDescriptors := make([]v1.Descriptor, len(layers))
for i := range layers {
layerDescriptors[i] = layers[i].Descriptor
if err := resolver.Push(ctx, named, layers[i].Descriptor, layers[i].Data); err != nil {
return err
}

Check warning on line 75 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L68-L75

Added lines #L68 - L75 were not covered by tests
}

if ociVersion != "" {
// if a version was explicitly specified, use it
return createAndPushManifest(ctx, resolver, named, layerDescriptors, ociVersion)
}

Check warning on line 81 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L78-L81

Added lines #L78 - L81 were not covered by tests

// try to push in the OCI 1.1 format but fallback to OCI 1.0 on 4xx errors
// (other than auth) since it's most likely the result of the registry not
// having support
err := createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_1)
var pushErr pusherrors.ErrUnexpectedStatus
if errors.As(err, &pushErr) && isNonAuthClientError(pushErr.StatusCode) {
// TODO(milas): show a warning here (won't work with logrus)
return createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_0)
}
return err

Check warning on line 92 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L86-L92

Added lines #L86 - L92 were not covered by tests
}

func createAndPushManifest(
ctx context.Context,
resolver *imagetools.Resolver,
named reference.Named,
layers []v1.Descriptor,
ociVersion api.OCIVersion,
) error {
toPush, err := generateManifest(layers, ociVersion)
if err != nil {
return err
}
for _, p := range toPush {
err = resolver.Push(ctx, named, p.Descriptor, p.Data)
if err != nil {
return err
}

Check warning on line 110 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L101-L110

Added lines #L101 - L110 were not covered by tests
}
return nil

Check warning on line 112 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L112

Added line #L112 was not covered by tests
}

func isNonAuthClientError(statusCode int) bool {
if statusCode < 400 || statusCode >= 500 {
// not a client error
return false
}
for _, v := range clientAuthStatusCodes {
if statusCode == v {
// client auth error
return false
}

Check warning on line 124 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L115-L124

Added lines #L115 - L124 were not covered by tests
}
// any other 4xx client error
return true

Check warning on line 127 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L127

Added line #L127 was not covered by tests
}

func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) ([]Pushable, error) {
var toPush []Pushable
var config v1.Descriptor
var artifactType string
switch ociCompat {
case api.OCIVersion1_0:
configData, err := json.Marshal(v1.ImageConfig{})
if err != nil {
return nil, err
}
config = v1.Descriptor{
MediaType: v1.MediaTypeImageConfig,
Digest: digest.FromBytes(configData),
Size: int64(len(configData)),
}
// N.B. OCI 1.0 does NOT support specifying the artifact type, so it's
// left as an empty string to omit it from the marshaled JSON
artifactType = ""
toPush = append(toPush, Pushable{Descriptor: config, Data: configData})
case api.OCIVersion1_1:
config = v1.DescriptorEmptyJSON
artifactType = "application/vnd.docker.compose.project"
// N.B. the descriptor has the data embedded in it
toPush = append(toPush, Pushable{Descriptor: config, Data: nil})
default:
return nil, fmt.Errorf("unsupported OCI version: %s", ociCompat)

Check warning on line 155 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L130-L155

Added lines #L130 - L155 were not covered by tests
}

manifest, err := json.Marshal(v1.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: v1.MediaTypeImageManifest,
ArtifactType: artifactType,
Config: config,
Layers: layers,
Annotations: map[string]string{
"org.opencontainers.image.created": time.Now().Format(time.RFC3339),
},
})
if err != nil {
return nil, err
}

Check warning on line 170 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L158-L170

Added lines #L158 - L170 were not covered by tests

manifestDescriptor := v1.Descriptor{
MediaType: v1.MediaTypeImageManifest,
Digest: digest.FromString(string(manifest)),
Size: int64(len(manifest)),
Annotations: map[string]string{
"com.docker.compose.version": api.ComposeVersion,
},
ArtifactType: artifactType,
}
toPush = append(toPush, Pushable{Descriptor: manifestDescriptor, Data: manifest})
return toPush, nil

Check warning on line 182 in internal/ocipush/push.go

View check run for this annotation

Codecov / codecov/patch

internal/ocipush/push.go#L172-L182

Added lines #L172 - L182 were not covered by tests
}
19 changes: 19 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,28 @@ type PortOptions struct {
Index int
}

// OCIVersion controls manifest generation to ensure compatibility
// with different registries.
//
// Currently, this is not exposed as an option to the user – Compose uses
// OCI 1.0 mode automatically for ECR registries based on domain and OCI 1.1
// for all other registries.
//
// There are likely other popular registries that do not support the OCI 1.1
// format, so it might make sense to expose this as a CLI flag or see if
// there's a way to generically probe the registry for support level.
type OCIVersion string

const (
OCIVersion1_0 OCIVersion = "1.0"
OCIVersion1_1 OCIVersion = "1.1"
)

// PublishOptions group options of the Publish API
type PublishOptions struct {
ResolveImageDigests bool

OCIVersion OCIVersion
}

func (e Event) String() string {
Expand Down