mirror of
https://github.com/docker/compose.git
synced 2025-04-08 17:05:13 +02:00
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:
parent
8026d0e2f2
commit
111ad3b039
@ -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
|
||||
}
|
||||
}
|
||||
|
56
pkg/compose/publish_test.go
Normal file
56
pkg/compose/publish_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user