Skip to content

Commit 07602f2

Browse files
committed
publish Compose application as compose.yaml + images
Signed-off-by: Nicolas De Loof <[email protected]>
1 parent cf7e31f commit 07602f2

File tree

9 files changed

+189
-42
lines changed

9 files changed

+189
-42
lines changed

cmd/compose/publish.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type publishOptions struct {
3434
ociVersion string
3535
withEnvironment bool
3636
assumeYes bool
37+
app bool
3738
}
3839

3940
func publishCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command {
@@ -53,6 +54,7 @@ func publishCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Servic
5354
flags.StringVar(&opts.ociVersion, "oci-version", "", "OCI image/artifact specification version (automatically determined by default)")
5455
flags.BoolVar(&opts.withEnvironment, "with-env", false, "Include environment variables in the published OCI artifact")
5556
flags.BoolVarP(&opts.assumeYes, "yes", "y", false, `Assume "yes" as answer to all prompts`)
57+
flags.BoolVar(&opts.app, "app", false, "Published compose application (includes referenced images)")
5658
flags.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
5759
// assumeYes was introduced by mistake as `--y`
5860
if name == "y" {
@@ -76,7 +78,8 @@ func runPublish(ctx context.Context, dockerCli command.Cli, backend api.Service,
7678
}
7779

7880
return backend.Publish(ctx, project, repository, api.PublishOptions{
79-
ResolveImageDigests: opts.resolveImageDigests,
81+
ResolveImageDigests: opts.resolveImageDigests || opts.app,
82+
Application: opts.app,
8083
OCIVersion: api.OCIVersion(opts.ociVersion),
8184
WithEnvironment: opts.withEnvironment,
8285
AssumeYes: opts.assumeYes,

docs/reference/compose_publish.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Publish compose application
77

88
| Name | Type | Default | Description |
99
|:--------------------------|:---------|:--------|:-------------------------------------------------------------------------------|
10+
| `--app` | `bool` | | Published compose application (includes referenced images) |
1011
| `--dry-run` | `bool` | | Execute command in dry run mode |
1112
| `--oci-version` | `string` | | OCI image/artifact specification version (automatically determined by default) |
1213
| `--resolve-image-digests` | `bool` | | Pin image tags to digests |

docs/reference/docker_compose_alpha_publish.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ usage: docker compose alpha publish [OPTIONS] REPOSITORY[:TAG]
55
pname: docker compose alpha
66
plink: docker_compose_alpha.yaml
77
options:
8+
- option: app
9+
value_type: bool
10+
default_value: "false"
11+
description: Published compose application (includes referenced images)
12+
deprecated: false
13+
hidden: false
14+
experimental: false
15+
experimentalcli: false
16+
kubernetes: false
17+
swarm: false
818
- option: oci-version
919
value_type: string
1020
description: |

docs/reference/docker_compose_publish.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ usage: docker compose publish [OPTIONS] REPOSITORY[:TAG]
55
pname: docker compose
66
plink: docker_compose.yaml
77
options:
8+
- option: app
9+
value_type: bool
10+
default_value: "false"
11+
description: Published compose application (includes referenced images)
12+
deprecated: false
13+
hidden: false
14+
experimental: false
15+
experimentalcli: false
16+
kubernetes: false
17+
swarm: false
818
- option: oci-version
919
value_type: string
1020
description: |

internal/oci/push.go

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import (
2828

2929
"github.com/containerd/containerd/v2/core/remotes"
3030
pusherrors "github.com/containerd/containerd/v2/core/remotes/errors"
31-
"github.com/containerd/errdefs"
3231
"github.com/distribution/reference"
3332
"github.com/docker/compose/v2/pkg/api"
3433
"github.com/opencontainers/go-digest"
@@ -94,20 +93,20 @@ func DescriptorForEnvFile(path string, content []byte) v1.Descriptor {
9493
}
9594
}
9695

97-
func PushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) error {
96+
func PushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) (v1.Descriptor, error) {
9897
// Check if we need an extra empty layer for the manifest config
9998
if ociVersion == api.OCIVersion1_1 || ociVersion == "" {
10099
err := push(ctx, resolver, named, v1.DescriptorEmptyJSON)
101100
if err != nil {
102-
return err
101+
return v1.Descriptor{}, err
103102
}
104103
}
105104
// prepare to push the manifest by pushing the layers
106105
layerDescriptors := make([]v1.Descriptor, len(layers))
107106
for i := range layers {
108107
layerDescriptors[i] = layers[i]
109108
if err := push(ctx, resolver, named, layers[i]); err != nil {
110-
return err
109+
return v1.Descriptor{}, err
111110
}
112111
}
113112

@@ -119,13 +118,13 @@ func PushManifest(ctx context.Context, resolver remotes.Resolver, named referenc
119118
// try to push in the OCI 1.1 format but fallback to OCI 1.0 on 4xx errors
120119
// (other than auth) since it's most likely the result of the registry not
121120
// having support
122-
err := createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_1)
121+
descriptor, err := createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_1)
123122
var pushErr pusherrors.ErrUnexpectedStatus
124123
if errors.As(err, &pushErr) && isNonAuthClientError(pushErr.StatusCode) {
125124
// TODO(milas): show a warning here (won't work with logrus)
126125
return createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_0)
127126
}
128-
return err
127+
return descriptor, err
129128
}
130129

131130
func push(ctx context.Context, resolver remotes.Resolver, ref reference.Named, descriptor v1.Descriptor) error {
@@ -134,37 +133,21 @@ func push(ctx context.Context, resolver remotes.Resolver, ref reference.Named, d
134133
return err
135134
}
136135

137-
pusher, err := resolver.Pusher(ctx, fullRef.String())
138-
if err != nil {
139-
return err
140-
}
141-
push, err := pusher.Push(ctx, descriptor)
142-
if errdefs.IsAlreadyExists(err) {
143-
return nil
144-
}
145-
if err != nil {
146-
return err
147-
}
148-
defer func() {
149-
_ = push.Close()
150-
}()
151-
152-
_, err = push.Write(descriptor.Data)
153-
return err
136+
return Push(ctx, resolver, fullRef, descriptor)
154137
}
155138

156-
func createAndPushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) error {
157-
toPush, err := generateManifest(layers, ociVersion)
139+
func createAndPushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) (v1.Descriptor, error) {
140+
descriptor, toPush, err := generateManifest(layers, ociVersion)
158141
if err != nil {
159-
return err
142+
return v1.Descriptor{}, err
160143
}
161144
for _, p := range toPush {
162145
err = push(ctx, resolver, named, p)
163146
if err != nil {
164-
return err
147+
return v1.Descriptor{}, err
165148
}
166149
}
167-
return nil
150+
return descriptor, nil
168151
}
169152

170153
func isNonAuthClientError(statusCode int) bool {
@@ -175,7 +158,7 @@ func isNonAuthClientError(statusCode int) bool {
175158
return !slices.Contains(clientAuthStatusCodes, statusCode)
176159
}
177160

178-
func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) ([]v1.Descriptor, error) {
161+
func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) (v1.Descriptor, []v1.Descriptor, error) {
179162
var toPush []v1.Descriptor
180163
var config v1.Descriptor
181164
var artifactType string
@@ -205,10 +188,9 @@ func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) ([]v1.De
205188
case api.OCIVersion1_1:
206189
config = v1.DescriptorEmptyJSON
207190
artifactType = ComposeProjectArtifactType
208-
// N.B. the descriptor has the data embedded in it
209191
toPush = append(toPush, config)
210192
default:
211-
return nil, fmt.Errorf("unsupported OCI version: %s", ociCompat)
193+
return v1.Descriptor{}, nil, fmt.Errorf("unsupported OCI version: %s", ociCompat)
212194
}
213195

214196
manifest, err := json.Marshal(v1.Manifest{
@@ -222,7 +204,7 @@ func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) ([]v1.De
222204
},
223205
})
224206
if err != nil {
225-
return nil, err
207+
return v1.Descriptor{}, nil, err
226208
}
227209

228210
manifestDescriptor := v1.Descriptor{
@@ -236,5 +218,5 @@ func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) ([]v1.De
236218
Data: manifest,
237219
}
238220
toPush = append(toPush, manifestDescriptor)
239-
return toPush, nil
221+
return manifestDescriptor, toPush, nil
240222
}

internal/oci/resolver.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@ package oci
1919
import (
2020
"context"
2121
"io"
22+
"net/url"
23+
"strings"
2224

2325
"github.com/containerd/containerd/v2/core/remotes"
2426
"github.com/containerd/containerd/v2/core/remotes/docker"
27+
"github.com/containerd/containerd/v2/pkg/labels"
28+
"github.com/containerd/errdefs"
2529
"github.com/distribution/reference"
2630
"github.com/docker/cli/cli/config/configfile"
2731
"github.com/docker/compose/v2/internal/registry"
32+
"github.com/moby/buildkit/util/contentutil"
2833
spec "github.com/opencontainers/image-spec/specs-go/v1"
2934
)
3035

@@ -70,3 +75,60 @@ func Get(ctx context.Context, resolver remotes.Resolver, ref reference.Named) (s
7075
}
7176
return descriptor, content, nil
7277
}
78+
79+
func Copy(ctx context.Context, resolver remotes.Resolver, image reference.Named, named reference.Named) (spec.Descriptor, error) {
80+
src, desc, err := resolver.Resolve(ctx, image.String())
81+
if err != nil {
82+
return spec.Descriptor{}, err
83+
}
84+
if desc.Annotations == nil {
85+
desc.Annotations = make(map[string]string)
86+
}
87+
// set LabelDistributionSource so push will actually use a registry mount
88+
refspec := reference.TrimNamed(image).String()
89+
u, err := url.Parse("dummy://" + refspec)
90+
if err != nil {
91+
return spec.Descriptor{}, err
92+
}
93+
source, repo := u.Hostname(), strings.TrimPrefix(u.Path, "/")
94+
desc.Annotations[labels.LabelDistributionSource+"."+source] = repo
95+
96+
p, err := resolver.Pusher(ctx, named.Name())
97+
if err != nil {
98+
return spec.Descriptor{}, err
99+
}
100+
f, err := resolver.Fetcher(ctx, src)
101+
if err != nil {
102+
return spec.Descriptor{}, err
103+
}
104+
105+
err = contentutil.CopyChain(ctx,
106+
contentutil.FromPusher(p),
107+
contentutil.FromFetcher(f), desc)
108+
return desc, err
109+
}
110+
111+
func Push(ctx context.Context, resolver remotes.Resolver, ref reference.Named, descriptor spec.Descriptor) error {
112+
pusher, err := resolver.Pusher(ctx, ref.String())
113+
if err != nil {
114+
return err
115+
}
116+
ctx = remotes.WithMediaTypeKeyPrefix(ctx, ComposeYAMLMediaType, "artifact-")
117+
ctx = remotes.WithMediaTypeKeyPrefix(ctx, ComposeEnvFileMediaType, "artifact-")
118+
ctx = remotes.WithMediaTypeKeyPrefix(ctx, ComposeEmptyConfigMediaType, "config-")
119+
ctx = remotes.WithMediaTypeKeyPrefix(ctx, spec.MediaTypeEmptyJSON, "config-")
120+
121+
push, err := pusher.Push(ctx, descriptor)
122+
if errdefs.IsAlreadyExists(err) {
123+
return nil
124+
}
125+
if err != nil {
126+
return err
127+
}
128+
defer func() {
129+
_ = push.Close()
130+
}()
131+
132+
_, err = push.Write(descriptor.Data)
133+
return err
134+
}

pkg/api/api.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,10 @@ const (
444444
// PublishOptions group options of the Publish API
445445
type PublishOptions struct {
446446
ResolveImageDigests bool
447+
Application bool
447448
WithEnvironment bool
448-
AssumeYes bool
449449

450+
AssumeYes bool
450451
OCIVersion OCIVersion
451452
}
452453

pkg/compose/publish.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"bytes"
2121
"context"
2222
"crypto/sha256"
23+
"encoding/json"
2324
"errors"
2425
"fmt"
2526
"io"
@@ -36,6 +37,8 @@ import (
3637
"github.com/docker/compose/v2/pkg/compose/transform"
3738
"github.com/docker/compose/v2/pkg/progress"
3839
"github.com/docker/compose/v2/pkg/prompt"
40+
"github.com/opencontainers/go-digest"
41+
"github.com/opencontainers/image-spec/specs-go"
3942
v1 "github.com/opencontainers/image-spec/specs-go/v1"
4043
)
4144

@@ -45,6 +48,7 @@ func (s *composeService) Publish(ctx context.Context, project *types.Project, re
4548
}, s.stdinfo(), "Publishing")
4649
}
4750

51+
//nolint:gocyclo
4852
func (s *composeService) publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
4953
accept, err := s.preChecks(project, options)
5054
if err != nil {
@@ -106,7 +110,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
106110
Status: progress.Working,
107111
})
108112
if !s.dryRun {
109-
err = oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
113+
descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
110114
if err != nil {
111115
w.Event(progress.Event{
112116
ID: repository,
@@ -115,6 +119,50 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
115119
})
116120
return err
117121
}
122+
123+
if options.Application {
124+
manifests := []v1.Descriptor{}
125+
for _, service := range project.Services {
126+
ref, err := reference.ParseDockerRef(service.Image)
127+
if err != nil {
128+
return err
129+
}
130+
131+
manifest, err := oci.Copy(ctx, resolver, ref, named)
132+
if err != nil {
133+
return err
134+
}
135+
manifests = append(manifests, manifest)
136+
}
137+
138+
descriptor.Data = nil
139+
index, err := json.Marshal(v1.Index{
140+
Versioned: specs.Versioned{SchemaVersion: 2},
141+
MediaType: v1.MediaTypeImageIndex,
142+
Manifests: manifests,
143+
Subject: &descriptor,
144+
Annotations: map[string]string{
145+
"com.docker.compose.version": api.ComposeVersion,
146+
},
147+
})
148+
if err != nil {
149+
return err
150+
}
151+
imagesDescriptor := v1.Descriptor{
152+
MediaType: v1.MediaTypeImageIndex,
153+
ArtifactType: oci.ComposeProjectArtifactType,
154+
Digest: digest.FromString(string(index)),
155+
Size: int64(len(index)),
156+
Annotations: map[string]string{
157+
"com.docker.compose.version": api.ComposeVersion,
158+
},
159+
Data: index,
160+
}
161+
err = oci.Push(ctx, resolver, reference.TrimNamed(named), imagesDescriptor)
162+
if err != nil {
163+
return err
164+
}
165+
}
118166
}
119167
w.Event(progress.Event{
120168
ID: repository,

0 commit comments

Comments
 (0)