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

Currently, we publish Compose artifacts following the OCI 1.1
specification, which is still in the RC state.

As a result, not all registries support it yet. Most notably,
AWS ECR will reject certain OCI 1.1-compliant requests with
`405 Method Not Supported` with cryptic `Invalid JSON` errors.

This adds initial support for Compose to generate either an
OCI 1.0 or OCI 1.1 compatible manifest. Notably, the OCI 1.0
manifest will be missing the `application/vnd.docker.compose.project`
artifact type, as that does not exist in that version of the
spec. (Less importantly, it uses an empty `ImageConfig`
instead of the newer `application/vnd.oci.empty.v1+json` media
type for the config.)

Currently, this is not exposed as an option (via CLI flags or
env vars). By default, OCI 1.1 is used unless the registry
domain is `amazonaws.com`, which indicates an ECR registry, so
Compose will instead use OCI 1.0.

Moving forward, we should decide how much we want to expose/
support different OCI versions and investigate if there's a
more generic way to feature probe the registry to avoid
maintaining a hardcoded list of domains, which is both tedious
and insufficient.

Signed-off-by: Milas Bowman <milas.bowman@docker.com>
This commit is contained in:
Milas Bowman 2023-12-01 13:33:00 -05:00 committed by Nicolas De loof
parent 8026d0e2f2
commit 111ad3b039
2 changed files with 159 additions and 37 deletions

View File

@ -19,8 +19,10 @@ package compose
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/compose-spec/compose-go/types"
@ -33,6 +35,23 @@ import (
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
// ociCompatibilityMode 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 ociCompatibilityMode string
const (
ociCompatibility1_0 ociCompatibilityMode = "1.0"
ociCompatibility1_1 ociCompatibilityMode = "1.1"
)
func (s *composeService) Publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
return s.publish(ctx, project, repository, options)
@ -45,8 +64,6 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
return err
}
w := progress.ContextWriter(ctx)
named, err := reference.ParseDockerRef(repository)
if err != nil {
return err
@ -83,51 +100,25 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
layers = append(layers, layer)
}
emptyConfig, err := json.Marshal(v1.ImageConfig{})
ociCompat := inferOCIVersion(named)
toPush, err := s.generateManifest(layers, ociCompat)
if err != nil {
return err
}
configDescriptor := v1.Descriptor{
MediaType: "application/vnd.oci.empty.v1+json",
Digest: digest.FromBytes(emptyConfig),
Size: int64(len(emptyConfig)),
}
var imageManifest []byte
if !s.dryRun {
err = resolver.Push(ctx, named, configDescriptor, emptyConfig)
if err != nil {
return err
}
imageManifest, err = json.Marshal(v1.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: v1.MediaTypeImageManifest,
ArtifactType: "application/vnd.docker.compose.project",
Config: configDescriptor,
Layers: layers,
Annotations: map[string]string{
"org.opencontainers.image.created": time.Now().Format(time.RFC3339),
},
})
if err != nil {
return err
}
}
w := progress.ContextWriter(ctx)
w.Event(progress.Event{
ID: repository,
Text: "publishing",
Status: progress.Working,
})
if !s.dryRun {
err = resolver.Push(ctx, named, v1.Descriptor{
MediaType: v1.MediaTypeImageManifest,
Digest: digest.FromString(string(imageManifest)),
Size: int64(len(imageManifest)),
Annotations: map[string]string{
"com.docker.compose.version": api.ComposeVersion,
},
ArtifactType: "application/vnd.docker.compose.project",
}, imageManifest)
for _, p := range toPush {
err = resolver.Push(ctx, named, p.Descriptor, p.Data)
if err != nil {
return err
}
}
if err != nil {
w.Event(progress.Event{
ID: repository,
@ -145,6 +136,66 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
return nil
}
type push struct {
Descriptor v1.Descriptor
Data []byte
}
func (s *composeService) generateManifest(layers []v1.Descriptor, ociCompat ociCompatibilityMode) ([]push, error) {
var toPush []push
var config v1.Descriptor
var artifactType string
switch ociCompat {
case ociCompatibility1_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, push{Descriptor: config, Data: configData})
case ociCompatibility1_1:
config = v1.DescriptorEmptyJSON
artifactType = "application/vnd.docker.compose.project"
// N.B. the descriptor has the data embedded in it
toPush = append(toPush, push{Descriptor: config, Data: nil})
default:
return nil, fmt.Errorf("unsupported OCI version: %s", ociCompat)
}
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
}
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, push{Descriptor: manifestDescriptor, Data: manifest})
return toPush, nil
}
func (s *composeService) generateImageDigestsOverride(ctx context.Context, project *types.Project) ([]byte, error) {
project.ApplyProfiles([]string{"*"})
err := project.ResolveImages(func(named reference.Named) (digest.Digest, error) {
@ -202,3 +253,18 @@ func statusFor(err error) progress.EventStatus {
}
return progress.Done
}
// inferOCIVersion uses OCI 1.1 by default but falls back to OCI 1.0 if the
// registry domain is known to require it.
//
// This is not ideal - with private registries, there isn't a bounded set of
// domains. As it stands, it's primarily intended for compatibility with AWS
// Elastic Container Registry (ECR) due to its ubiquity.
func inferOCIVersion(named reference.Named) ociCompatibilityMode {
domain := reference.Domain(named)
if strings.HasSuffix(domain, "amazonaws.com") {
return ociCompatibility1_0
} else {
return ociCompatibility1_1
}
}

View File

@ -0,0 +1,56 @@
/*
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 compose
import (
"testing"
"github.com/distribution/reference"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInferOCIVersion(t *testing.T) {
tests := []struct {
ref string
want ociCompatibilityMode
}{
{
ref: "175142243308.dkr.ecr.us-east-1.amazonaws.com/compose:test",
want: ociCompatibility1_0,
},
{
ref: "my-image:latest",
want: ociCompatibility1_1,
},
{
ref: "docker.io/docker/compose:test",
want: ociCompatibility1_1,
},
{
ref: "ghcr.io/docker/compose:test",
want: ociCompatibility1_1,
},
}
for _, tt := range tests {
t.Run(tt.ref, func(t *testing.T) {
named, err := reference.ParseDockerRef(tt.ref)
require.NoErrorf(t, err, "Test issue - invalid ref: %s", tt.ref)
assert.Equalf(t, tt.want, inferOCIVersion(named), "inferOCIVersion(%s)", tt.ref)
})
}
}