Pārlūkot izejas kodu

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 <[email protected]>
Milas Bowman 1 gadu atpakaļ
vecāks
revīzija
111ad3b039
2 mainītis faili ar 159 papildinājumiem un 37 dzēšanām
  1. 103 37
      pkg/compose/publish.go
  2. 56 0
      pkg/compose/publish_test.go

+ 103 - 37
pkg/compose/publish.go

@@ -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 - 0
pkg/compose/publish_test.go

@@ -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)
+		})
+	}
+}