Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions cmd/compose/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package compose
import (
"context"
"errors"
"strings"
"fmt"

"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
Expand All @@ -38,6 +40,7 @@ type publishOptions struct {
assumeYes bool
app bool
insecureRegistry bool
annotations []string
}

func publishCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
Expand All @@ -59,6 +62,7 @@ func publishCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Ba
flags.BoolVarP(&opts.assumeYes, "yes", "y", false, `Assume "yes" as answer to all prompts`)
flags.BoolVar(&opts.app, "app", false, "Published compose application (includes referenced images)")
flags.BoolVar(&opts.insecureRegistry, "insecure-registry", false, "Use insecure registry")
flags.StringArrayVar(&opts.annotations, "annotation", nil, "Add custom metadata to the published OCI artifact (format: key=value)")
flags.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
// assumeYes was introduced by mistake as `--y`
if name == "y" {
Expand Down Expand Up @@ -92,11 +96,33 @@ func runPublish(ctx context.Context, dockerCli command.Cli, backendOptions *Back
return errors.New("cannot publish compose file with local includes")
}

annotations, err := parseAnnotations(opts.annotations)
if err != nil {
return err
}

return backend.Publish(ctx, project, repository, api.PublishOptions{
ResolveImageDigests: opts.resolveImageDigests || opts.app,
Application: opts.app,
OCIVersion: api.OCIVersion(opts.ociVersion),
WithEnvironment: opts.withEnvironment,
InsecureRegistry: opts.insecureRegistry,
Annotations: annotations,
})
}

//helper function
func parseAnnotations(raw []string) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
annotations := make(map[string]string, len(raw))
for _, a := range raw {
key, value, ok := strings.Cut(a, "=")
if !ok || key == "" {
return nil, fmt.Errorf("invalid annotation %q: expected format key=value", a)
}
annotations[key] = value
}
return annotations, nil
}
42 changes: 42 additions & 0 deletions cmd/compose/publish_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
Copyright 2020 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"

"gotest.tools/v3/assert"
)

func TestParseAnnotations(t *testing.T) {
t.Run("valid annotation", func(t *testing.T) {
result, err := parseAnnotations([]string{"foo=bar"})
assert.NilError(t, err)
assert.DeepEqual(t, result, map[string]string{"foo": "bar"})
})

t.Run("invalid annotation missing equals sign", func(t *testing.T) {
_, err := parseAnnotations([]string{"foobar"})
assert.Error(t, err, `invalid annotation "foobar": expected format key=value`)
})

t.Run("empty slice", func(t *testing.T) {
result, err := parseAnnotations([]string{})
assert.NilError(t, err)
assert.Check(t, result == nil)
})
}
17 changes: 9 additions & 8 deletions docs/reference/compose_publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ Publish compose application

### Options

| Name | Type | Default | Description |
|:--------------------------|:---------|:--------|:-------------------------------------------------------------------------------|
| `--app` | `bool` | | Published compose application (includes referenced images) |
| `--dry-run` | `bool` | | Execute command in dry run mode |
| `--oci-version` | `string` | | OCI image/artifact specification version (automatically determined by default) |
| `--resolve-image-digests` | `bool` | | Pin image tags to digests |
| `--with-env` | `bool` | | Include environment variables in the published OCI artifact |
| `-y`, `--yes` | `bool` | | Assume "yes" as answer to all prompts |
| Name | Type | Default | Description |
|:--------------------------|:--------------|:--------|:-------------------------------------------------------------------------------|
| `--annotation` | `stringArray` | | Add custom metadata to the published OCI artifact (format: key=value) |
| `--app` | `bool` | | Published compose application (includes referenced images) |
| `--dry-run` | `bool` | | Execute command in dry run mode |
| `--oci-version` | `string` | | OCI image/artifact specification version (automatically determined by default) |
| `--resolve-image-digests` | `bool` | | Pin image tags to digests |
| `--with-env` | `bool` | | Include environment variables in the published OCI artifact |
| `-y`, `--yes` | `bool` | | Assume "yes" as answer to all prompts |


<!---MARKER_GEN_END-->
Expand Down
11 changes: 11 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,17 @@ usage: docker compose alpha publish [OPTIONS] REPOSITORY[:TAG]
pname: docker compose alpha
plink: docker_compose_alpha.yaml
options:
- option: annotation
value_type: stringArray
default_value: '[]'
description: |
Add custom metadata to the published OCI artifact (format: key=value)
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: app
value_type: bool
default_value: "false"
Expand Down
11 changes: 11 additions & 0 deletions docs/reference/docker_compose_publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ usage: docker compose publish [OPTIONS] REPOSITORY[:TAG]
pname: docker compose
plink: docker_compose.yaml
options:
- option: annotation
value_type: stringArray
default_value: '[]'
description: |
Add custom metadata to the published OCI artifact (format: key=value)
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: app
value_type: bool
default_value: "false"
Expand Down
26 changes: 16 additions & 10 deletions internal/oci/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func DescriptorForEnvFile(path string, content []byte) v1.Descriptor {
}
}

func PushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) (v1.Descriptor, error) {
func PushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion, extraAnnotations map[string]string) (v1.Descriptor, error) {
// Check if we need an extra empty layer for the manifest config
if ociVersion == api.OCIVersion1_1 || ociVersion == "" {
err := push(ctx, resolver, named, v1.DescriptorEmptyJSON)
Expand All @@ -113,17 +113,17 @@ func PushManifest(ctx context.Context, resolver remotes.Resolver, named referenc

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

// 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
descriptor, err := createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_1)
descriptor, err := createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_1, extraAnnotations)
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 createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_0, extraAnnotations)
}
return descriptor, err
}
Expand All @@ -137,8 +137,8 @@ func push(ctx context.Context, resolver remotes.Resolver, ref reference.Named, d
return Push(ctx, resolver, fullRef, descriptor)
}

func createAndPushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) (v1.Descriptor, error) {
descriptor, toPush, err := generateManifest(layers, ociVersion)
func createAndPushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion, extraAnnotations map[string]string) (v1.Descriptor, error) {
descriptor, toPush, err := generateManifest(layers, ociVersion, extraAnnotations)
if err != nil {
return v1.Descriptor{}, err
}
Expand All @@ -159,7 +159,7 @@ func isNonAuthClientError(statusCode int) bool {
return !slices.Contains(clientAuthStatusCodes, statusCode)
}

func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) (v1.Descriptor, []v1.Descriptor, error) {
func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion, extraAnnotations map[string]string) (v1.Descriptor, []v1.Descriptor, error) {
var toPush []v1.Descriptor
var config v1.Descriptor
var artifactType string
Expand Down Expand Up @@ -194,15 +194,21 @@ func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) (v1.Desc
return v1.Descriptor{}, nil, fmt.Errorf("unsupported OCI version: %s", ociCompat)
}

annotations := map[string]string{
"org.opencontainers.image.created": time.Now().Format(time.RFC3339),
}
for k, v := range extraAnnotations {
annotations[k] = v
}

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),
},
Annotations: annotations,

})
if err != nil {
return v1.Descriptor{}, nil, err
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ type PublishOptions struct {
OCIVersion OCIVersion
// Use plain HTTP to access registry. Should only be used for testing purpose
InsecureRegistry bool
// Annotations are custom key/value pairs added to the published manifest
Annotations map[string]string
}

func (e Event) String() string {
Expand Down
2 changes: 1 addition & 1 deletion pkg/compose/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re

resolver := oci.NewResolver(s.configFile(), desktop.ProxyTransportFor(ctx, s.apiClient()), insecureRegistries...)

descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion, options.Annotations)
if err != nil {
s.events.On(api.Resource{
ID: repository,
Expand Down