Skip to content
Draft
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
2 changes: 1 addition & 1 deletion cmd/compose/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func runConvert(ctx context.Context, dockerCli command.Cli, p *ProjectOptions, o
return err
}

project, _, err := p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes)
project, _, err := p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, keepVolumes)
if err != nil {
return err
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,10 @@ func (o *ProjectOptions) ociOptions() api.OCIOptions {
}
}

// keepVolumes loads the project with its `volumes` as they are declared, instead of moving to `mounts` the ones the
// short syntax can't say, for what renders the model or hands it over to something else than the engine.
var keepVolumes = cli.WithLoadOptions(func(o *loader.Options) { o.ConvertVolumesToMounts = false })

func (o *ProjectOptions) toProjectOptions(po ...cli.ProjectOptionsFn) (*cli.ProjectOptions, error) {
opts := []cli.ProjectOptionsFn{
cli.WithWorkingDirectory(o.ProjectDir),
Expand Down
142 changes: 76 additions & 66 deletions cmd/compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type configOptions struct {
// ToProject always warns: every config subcommand renders the resolved
// model to the user, so any unsupported-attribute finding is relevant here.
func (o *configOptions) ToProject(ctx context.Context, dockerCli command.Cli, backend api.ProjectLoader, services []string, po ...cli.ProjectOptionsFn) (*types.Project, error) {
po = append(po, keepVolumes)
po = append(po, o.toProjectOptionsFns()...)
project, _, err := o.ProjectOptions.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, po...)
return project, err
Expand Down Expand Up @@ -269,34 +270,34 @@ func runConfigInterpolate(ctx context.Context, dockerCli command.Cli, opts confi
return content, nil
}

// imageOnlySpec returns the spec with all attributes removed but image, and the `type: image` volumes and mounts
func imageOnlySpec(spec types.ContainerSpec) types.ContainerSpec {
only := types.ContainerSpec{Image: spec.Image}
for _, vol := range spec.Volumes {
if vol.Type == types.VolumeTypeImage {
only.Volumes = append(only.Volumes, vol)
}
}
for _, m := range spec.Mounts {
if m.Type == types.VolumeTypeImage {
only.Mounts = append(only.Mounts, m)
}
}
return only
}

// imagesOnly return project with all attributes removed but service.images and `type: image` volumes
func imagesOnly(project *types.Project) *types.Project {
digests := types.Services{}
for name, config := range project.Services {
service := types.ServiceConfig{
ContainerSpec: types.ContainerSpec{Image: config.Image},
}
for _, vol := range config.Volumes {
if vol.Type == types.VolumeTypeImage {
service.Volumes = append(service.Volumes, vol)
}
}
digests[name] = service
digests[name] = types.ServiceConfig{ContainerSpec: imageOnlySpec(config.ContainerSpec)}
}
var jobDigests types.Jobs
if len(project.Jobs) > 0 {
jobDigests = types.Jobs{}
}
for name, config := range project.Jobs {
job := types.JobConfig{
ContainerSpec: types.ContainerSpec{Image: config.Image},
}
for _, vol := range config.Volumes {
if vol.Type == types.VolumeTypeImage {
job.Volumes = append(job.Volumes, vol)
}
}
jobDigests[name] = job
jobDigests[name] = types.JobConfig{ContainerSpec: imageOnlySpec(config.ContainerSpec)}
}
project = &types.Project{Services: digests, Jobs: jobDigests}
return project
Expand Down Expand Up @@ -374,16 +375,16 @@ func lockModel(model map[string]any) {
for key := range service {
switch key {
case "image":
case "volumes":
if volumes := imageVolumes(service); len(volumes) > 0 {
// write back as []any to keep the raw-model volumes type unchanged
filtered := make([]any, len(volumes))
for i, volume := range volumes {
filtered[i] = volume
case "volumes", "mounts":
if entries := imageEntries(service, key); len(entries) > 0 {
// write back as []any to keep the raw-model type unchanged
filtered := make([]any, len(entries))
for i, entry := range entries {
filtered[i] = entry
}
service["volumes"] = filtered
service[key] = filtered
} else {
delete(service, "volumes")
delete(service, key)
}
default:
delete(service, key)
Expand Down Expand Up @@ -423,21 +424,11 @@ func resolveServiceImageDigests(ctx context.Context, dockerCli command.Cli, mode
services := model["services"].(map[string]any)
for name, s := range services {
service := s.(map[string]any)
config := types.ServiceConfig{}
if image, ok := service["image"].(string); ok {
config.Image = image
}
config := imageOnlyConfig(service)
for _, hook := range preStartHooks(service) {
image, _ := hook["image"].(string)
config.PreStart = append(config.PreStart, types.PreStartHook{ContainerSpec: types.ContainerSpec{Image: image}})
}
for _, volume := range imageVolumes(service) {
source, _ := volume["source"].(string)
config.Volumes = append(config.Volumes, types.ServiceVolumeConfig{
Type: types.VolumeTypeImage,
Source: source,
})
}
p.Services[name] = config
}

Expand All @@ -459,15 +450,49 @@ func resolveServiceImageDigests(ctx context.Context, dockerCli command.Cli, mode
hook["image"] = image
}
}
for i, volume := range imageVolumes(service) {
if source := config.Volumes[i].Source; source != "" {
volume["source"] = source
}
}
setImageSources(service, config)
}
return nil
}

// imageOnlyConfig is the pseudo-service of a raw model: its image, and the sources of its `type: image` volumes and mounts
func imageOnlyConfig(raw map[string]any) types.ServiceConfig {
config := types.ServiceConfig{}
if image, ok := raw["image"].(string); ok {
config.Image = image
}
for _, volume := range imageEntries(raw, "volumes") {
source, _ := volume["source"].(string)
config.Volumes = append(config.Volumes, types.ServiceVolumeConfig{
Type: types.VolumeTypeImage,
Source: source,
})
}
for _, mount := range imageEntries(raw, "mounts") {
source, _ := mount["source"].(string)
config.Mounts = append(config.Mounts, types.ServiceMountConfig{
Type: types.VolumeTypeImage,
Source: source,
})
}
return config
}

// setImageSources folds the resolved sources of the `type: image` volumes and mounts of the pseudo-service back
// into the raw model, leaving untouched the ones absent from it (empty Source)
func setImageSources(raw map[string]any, config types.ServiceConfig) {
for i, volume := range imageEntries(raw, "volumes") {
if source := config.Volumes[i].Source; source != "" {
volume["source"] = source
}
}
for i, mount := range imageEntries(raw, "mounts") {
if source := config.Mounts[i].Source; source != "" {
mount["source"] = source
}
}
}

// resolveJobImageDigests mirrors resolveServiceImageDigests for model["jobs"].
// WithImagesResolved only walks a project's Services, so jobs are dressed as
// services in their own pseudo-project -- keyed by job name, which can't
Expand All @@ -478,18 +503,7 @@ func resolveJobImageDigests(ctx context.Context, dockerCli command.Cli, model ma
jobs := model["jobs"].(map[string]any)
for name, j := range jobs {
job := j.(map[string]any)
config := types.ServiceConfig{}
if image, ok := job["image"].(string); ok {
config.Image = image
}
for _, volume := range imageVolumes(job) {
source, _ := volume["source"].(string)
config.Volumes = append(config.Volumes, types.ServiceVolumeConfig{
Type: types.VolumeTypeImage,
Source: source,
})
}
jobsAsServices.Services[name] = config
jobsAsServices.Services[name] = imageOnlyConfig(job)
}

jobsAsServices, err := jobsAsServices.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client()))
Expand All @@ -503,11 +517,7 @@ func resolveJobImageDigests(ctx context.Context, dockerCli command.Cli, model ma
if config.Image != "" {
job["image"] = config.Image
}
for i, volume := range imageVolumes(job) {
if source := config.Volumes[i].Source; source != "" {
volume["source"] = source
}
}
setImageSources(job, config)
}
return nil
}
Expand All @@ -527,17 +537,17 @@ func preStartHooks(service map[string]any) []map[string]any {
return result
}

// imageVolumes returns the `type: image` volume declarations of a service raw model
func imageVolumes(service map[string]any) []map[string]any {
volumes, ok := service["volumes"].([]any)
// imageEntries returns the `type: image` declarations of the `volumes` or `mounts` key of a service raw model
func imageEntries(service map[string]any, key string) []map[string]any {
entries, ok := service[key].([]any)
if !ok {
return nil
}
var images []map[string]any
for _, v := range volumes {
volume, ok := v.(map[string]any)
if ok && volume["type"] == types.VolumeTypeImage {
images = append(images, volume)
for _, e := range entries {
entry, ok := e.(map[string]any)
if ok && entry["type"] == types.VolumeTypeImage {
images = append(images, entry)
}
}
return images
Expand Down
18 changes: 18 additions & 0 deletions cmd/compose/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ func TestResolveImageDigests(t *testing.T) {
map[string]any{"type": "bind", "source": "/host", "target": "/bind"},
"./data:/short",
},
// `mounts` entries are pinned the same way as `volumes` ones
"mounts": []any{
map[string]any{"type": "image", "source": "someimage:latest", "target": "/mounted"},
map[string]any{"type": "bind", "source": "/host", "target": "/bind-mounted"},
},
},
"builder": map[string]any{
"image": "docker.io/library/pinned@" + testDigest,
Expand Down Expand Up @@ -115,6 +120,9 @@ func TestResolveImageDigests(t *testing.T) {
assert.Equal(t, volumes[2].(map[string]any)["source"], "docker.io/library/builder:latest@"+builderDigest)
assert.Equal(t, volumes[3].(map[string]any)["source"], "/host")
assert.Equal(t, volumes[4], "./data:/short")
mounts := service["mounts"].([]any)
assert.Equal(t, mounts[0].(map[string]any)["source"], "docker.io/library/someimage:latest@"+volumeDigest)
assert.Equal(t, mounts[1].(map[string]any)["source"], "/host")
assert.Equal(t, services["builder"].(map[string]any)["image"], "docker.io/library/pinned@"+testDigest)
dataVolumes := services["data"].(map[string]any)["volumes"].([]any)
assert.Equal(t, dataVolumes[0].(map[string]any)["source"], "docker.io/library/someimage:latest@"+volumeDigest)
Expand Down Expand Up @@ -279,12 +287,19 @@ func TestLockModel(t *testing.T) {
map[string]any{"type": "image", "source": "docker.io/library/someimage@" + testDigest, "target": "/data"},
map[string]any{"type": "bind", "source": "/host", "target": "/bind"},
},
"mounts": []any{
map[string]any{"type": "image", "source": "docker.io/library/mounted@" + testDigest, "target": "/mounted"},
map[string]any{"type": "bind", "source": "/host", "target": "/bind-mounted"},
},
},
"b": map[string]any{
"image": "docker.io/library/alpine@" + testDigest,
"volumes": []any{
map[string]any{"type": "bind", "source": "/host", "target": "/bind"},
},
"mounts": []any{
map[string]any{"type": "bind", "source": "/host", "target": "/bind-mounted"},
},
},
},
"networks": map[string]any{"default": nil},
Expand All @@ -300,6 +315,9 @@ func TestLockModel(t *testing.T) {
"volumes": []any{
map[string]any{"type": "image", "source": "docker.io/library/someimage@" + testDigest, "target": "/data"},
},
"mounts": []any{
map[string]any{"type": "image", "source": "docker.io/library/mounted@" + testDigest, "target": "/mounted"},
},
},
"b": map[string]any{
"image": "docker.io/library/alpine@" + testDigest,
Expand Down
2 changes: 1 addition & 1 deletion cmd/compose/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func runPublish(ctx context.Context, dockerCli command.Cli, backendOptions *Back
return err
}

project, metrics, err := opts.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes)
project, metrics, err := opts.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, keepVolumes)
if err != nil {
return err
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/compose/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ import (
"errors"
"fmt"
"os"
"slices"
"strings"

composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/format"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
Expand Down Expand Up @@ -112,6 +114,9 @@ func (options runOptions) apply(project *types.Project) (*types.Project, error)
if err != nil {
return nil, err
}
// a volume of the command line replaces the one of the service at the same target
target.Volumes = slices.DeleteFunc(slices.Clone(target.Volumes), func(v types.ServiceVolumeConfig) bool { return v.Target == volume.Target })
target.Mounts = slices.DeleteFunc(slices.Clone(target.Mounts), func(m types.ServiceMountConfig) bool { return m.Target == volume.Target })
target.Volumes = append(target.Volumes, volume)
}

Expand All @@ -121,6 +126,12 @@ func (options runOptions) apply(project *types.Project) (*types.Project, error)
break
}
}
if len(options.volumes) > 0 {
// the volumes of --volume come after the project was loaded
if err := loader.ConvertVolumesToMounts(project); err != nil {
return nil, err
}
}
return project, nil
}

Expand Down
29 changes: 29 additions & 0 deletions cmd/compose/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,32 @@ func TestJobTargetErr(t *testing.T) {
assert.Equal(t, err, original)
})
}

func TestRunVolumeReplacesTheOneOfTheServiceAtTheSameTarget(t *testing.T) {
project := &types.Project{
Services: types.Services{
"app": {Name: "app", ContainerSpec: types.ContainerSpec{
Image: "alpine",
Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeBind, Source: "/declared", Target: "/data"},
{Type: types.VolumeTypeBind, Source: "/other", Target: "/other"},
},
Mounts: []types.ServiceMountConfig{
{Type: types.VolumeTypeTmpfs, Target: "/scratch"},
},
}},
},
}

project, err := runOptions{Service: "app", volumes: []string{"/cli:/data", "/cli-scratch:/scratch"}}.apply(project)
assert.NilError(t, err)

app := project.Services["app"]
assert.Equal(t, len(app.Mounts), 0)
sources := map[string]string{}
for _, v := range app.Volumes {
sources[v.Target] = v.Source
}
assert.DeepEqual(t, sources, map[string]string{"/data": "/cli", "/other": "/other", "/scratch": "/cli-scratch"})
assert.Equal(t, len(app.Volumes), 3)
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,7 @@ exclude (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
)

// DEMO ONLY: compose-go with the `mounts` attribute, from https://github.com/compose-spec/compose-go/pull/979.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] go.mod replace directive points to an unreleased personal fork — must not be merged

go.mod substitutes the canonical github.com/compose-spec/compose-go/v2 module with the personal fork github.com/ndeloof/compose-go/v2 at a pseudo-version:

replace github.com/compose-spec/compose-go/v2 => github.com/ndeloof/compose-go/v2 v2.0.0-20250530105901-8e4f4d6ea43c

The adjacent comment says // DEMO ONLY: To be dropped once that PR is released. and the PR description confirms "it is a draft demo, not meant to be merged as is."

If this lands on main it ships a production binary that depends on an unofficial, mutable personal fork. The pseudo-version is covered by go.sum (content integrity for the pinned commit), but the fork's history can be rewritten, making the pinned commit unavailable and breaking fresh builds. The fix is to wait until compose-spec/compose-go#979 is released, then replace the directive with the proper released version.

Confidence Score
🟢 strong 100/100

// To be dropped once that PR is released.
replace github.com/compose-spec/compose-go/v2 => github.com/ndeloof/compose-go/v2 v2.0.1-0.20261102090230-d6e4087dc12b
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/compose-spec/compose-go/v2 v2.16.2 h1:pTp1DqQwJzFd85GzlWsSszKA1ABrV/ySqsClujIcWA8=
github.com/compose-spec/compose-go/v2 v2.16.2/go.mod h1:Q1+qtN4vhzEjGrnqRtzx1xa8raDZQlMUe3WJxndYNiQ=
github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ=
github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
Expand Down Expand Up @@ -183,6 +181,8 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ndeloof/compose-go/v2 v2.0.1-0.20261102090230-d6e4087dc12b h1:nZpjKCrUeyZa7XNscIyIXDd3vBeOVXWo8RQhfLXMB3E=
github.com/ndeloof/compose-go/v2 v2.0.1-0.20261102090230-d6e4087dc12b/go.mod h1:hDQhfvD3lgjW9Q2Vh69LmIg2usJTnvh9D/7xByuMdxc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
Expand Down
Loading
Loading