mirror of
https://github.com/docker/compose.git
synced 2025-07-24 22:24:41 +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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/compose-spec/compose-go/types"
|
"github.com/compose-spec/compose-go/types"
|
||||||
@ -33,6 +35,23 @@ import (
|
|||||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
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 {
|
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 progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
return s.publish(ctx, project, repository, options)
|
return s.publish(ctx, project, repository, options)
|
||||||
@ -45,8 +64,6 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
w := progress.ContextWriter(ctx)
|
|
||||||
|
|
||||||
named, err := reference.ParseDockerRef(repository)
|
named, err := reference.ParseDockerRef(repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -83,51 +100,25 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
|
|||||||
layers = append(layers, layer)
|
layers = append(layers, layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
emptyConfig, err := json.Marshal(v1.ImageConfig{})
|
ociCompat := inferOCIVersion(named)
|
||||||
|
toPush, err := s.generateManifest(layers, ociCompat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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{
|
w.Event(progress.Event{
|
||||||
ID: repository,
|
ID: repository,
|
||||||
Text: "publishing",
|
Text: "publishing",
|
||||||
Status: progress.Working,
|
Status: progress.Working,
|
||||||
})
|
})
|
||||||
if !s.dryRun {
|
if !s.dryRun {
|
||||||
err = resolver.Push(ctx, named, v1.Descriptor{
|
for _, p := range toPush {
|
||||||
MediaType: v1.MediaTypeImageManifest,
|
err = resolver.Push(ctx, named, p.Descriptor, p.Data)
|
||||||
Digest: digest.FromString(string(imageManifest)),
|
if err != nil {
|
||||||
Size: int64(len(imageManifest)),
|
return err
|
||||||
Annotations: map[string]string{
|
}
|
||||||
"com.docker.compose.version": api.ComposeVersion,
|
}
|
||||||
},
|
|
||||||
ArtifactType: "application/vnd.docker.compose.project",
|
|
||||||
}, imageManifest)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.Event(progress.Event{
|
w.Event(progress.Event{
|
||||||
ID: repository,
|
ID: repository,
|
||||||
@ -145,6 +136,66 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
|
|||||||
return nil
|
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) {
|
func (s *composeService) generateImageDigestsOverride(ctx context.Context, project *types.Project) ([]byte, error) {
|
||||||
project.ApplyProfiles([]string{"*"})
|
project.ApplyProfiles([]string{"*"})
|
||||||
err := project.ResolveImages(func(named reference.Named) (digest.Digest, error) {
|
err := project.ResolveImages(func(named reference.Named) (digest.Digest, error) {
|
||||||
@ -202,3 +253,18 @@ func statusFor(err error) progress.EventStatus {
|
|||||||
}
|
}
|
||||||
return progress.Done
|
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