diff --git a/go.mod b/go.mod index 317ace275..243503f7a 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/buger/goterm v1.0.4 - github.com/compose-spec/compose-go/v2 v2.5.0 + github.com/compose-spec/compose-go/v2 v2.5.1-0.20250409070949-8e1a035095ca github.com/containerd/containerd/v2 v2.0.4 github.com/containerd/platforms v1.0.0-rc.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc @@ -206,5 +206,3 @@ require ( sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) - -replace github.com/compose-spec/compose-go/v2 => github.com/glours/compose-go/v2 v2.0.0-20250403082600-80aa75f06535 diff --git a/go.sum b/go.sum index f2b2bed1e..2f92e5d03 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= 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.5.1-0.20250409070949-8e1a035095ca h1:4dH4DudeDunWTYetcJxQE65osreQKvaLtFLdl9CcqME= +github.com/compose-spec/compose-go/v2 v2.5.1-0.20250409070949-8e1a035095ca/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA= github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo= github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= @@ -167,8 +169,6 @@ github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQ github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/glours/compose-go/v2 v2.0.0-20250403082600-80aa75f06535 h1:S/P6v3QxsMpkKn+2OSMPNkfSkadSjSHoMGAc/eBZgMU= -github.com/glours/compose-go/v2 v2.0.0-20250403082600-80aa75f06535/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= diff --git a/pkg/compose/build.go b/pkg/compose/build.go index a9508e5af..1a2c3f94d 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -318,14 +318,17 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types. } func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]api.ImageSummary, error) { - var imageNames []string + imageNames := utils.Set[string]{} for _, s := range project.Services { imgName := api.GetImageNameOrDefault(s, project.Name) - if !utils.StringContains(imageNames, imgName) { - imageNames = append(imageNames, imgName) + imageNames.Add(imgName) + for _, volume := range s.Volumes { + if volume.Type == types.VolumeTypeImage { + imageNames.Add(volume.Source) + } } } - imgs, err := s.getImageSummaries(ctx, imageNames) + imgs, err := s.getImageSummaries(ctx, imageNames.Elements()) if err != nil { return nil, err } diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 2a92f92e3..8e7729b38 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -871,6 +871,15 @@ MOUNTS: } } } + if m.Type == mount.TypeImage { + version, err := s.RuntimeVersion(ctx) + if err != nil { + return nil, nil, err + } + if versions.LessThan(version, "1.48") { + return nil, nil, fmt.Errorf("volume with type=image require Docker Engine v28 or later") + } + } mounts = append(mounts, m) } return binds, mounts, nil @@ -1125,7 +1134,7 @@ func buildMount(project types.Project, volume types.ServiceVolumeConfig) (mount. } } - bind, vol, tmpfs := buildMountOptions(volume) + bind, vol, tmpfs, img := buildMountOptions(volume) if bind != nil { volume.Type = types.VolumeTypeBind @@ -1140,37 +1149,35 @@ func buildMount(project types.Project, volume types.ServiceVolumeConfig) (mount. BindOptions: bind, VolumeOptions: vol, TmpfsOptions: tmpfs, + ImageOptions: img, }, nil } -func buildMountOptions(volume types.ServiceVolumeConfig) (*mount.BindOptions, *mount.VolumeOptions, *mount.TmpfsOptions) { +func buildMountOptions(volume types.ServiceVolumeConfig) (*mount.BindOptions, *mount.VolumeOptions, *mount.TmpfsOptions, *mount.ImageOptions) { + if volume.Type != types.VolumeTypeBind && volume.Bind != nil { + logrus.Warnf("mount of type `%s` should not define `bind` option", volume.Type) + } + if volume.Type != types.VolumeTypeVolume && volume.Volume != nil { + logrus.Warnf("mount of type `%s` should not define `volume` option", volume.Type) + } + if volume.Type != types.VolumeTypeTmpfs && volume.Tmpfs != nil { + logrus.Warnf("mount of type `%s` should not define `tmpfs` option", volume.Type) + } + if volume.Type != types.VolumeTypeImage && volume.Image != nil { + logrus.Warnf("mount of type `%s` should not define `image` option", volume.Type) + } + switch volume.Type { case "bind": - if volume.Volume != nil { - logrus.Warnf("mount of type `bind` should not define `volume` option") - } - if volume.Tmpfs != nil { - logrus.Warnf("mount of type `bind` should not define `tmpfs` option") - } - return buildBindOption(volume.Bind), nil, nil + return buildBindOption(volume.Bind), nil, nil, nil case "volume": - if volume.Bind != nil { - logrus.Warnf("mount of type `volume` should not define `bind` option") - } - if volume.Tmpfs != nil { - logrus.Warnf("mount of type `volume` should not define `tmpfs` option") - } - return nil, buildVolumeOptions(volume.Volume), nil + return nil, buildVolumeOptions(volume.Volume), nil, nil case "tmpfs": - if volume.Bind != nil { - logrus.Warnf("mount of type `tmpfs` should not define `bind` option") - } - if volume.Volume != nil { - logrus.Warnf("mount of type `tmpfs` should not define `volume` option") - } - return nil, nil, buildTmpfsOptions(volume.Tmpfs) + return nil, nil, buildTmpfsOptions(volume.Tmpfs), nil + case "image": + return nil, nil, nil, buildImageOptions(volume.Image) } - return nil, nil, nil + return nil, nil, nil, nil } func buildBindOption(bind *types.ServiceVolumeBind) *mount.BindOptions { @@ -1199,7 +1206,7 @@ func buildVolumeOptions(vol *types.ServiceVolumeVolume) *mount.VolumeOptions { return &mount.VolumeOptions{ NoCopy: vol.NoCopy, Subpath: vol.Subpath, - // Labels: , // FIXME missing from model ? + Labels: vol.Labels, // DriverConfig: , // FIXME missing from model ? } } @@ -1214,6 +1221,15 @@ func buildTmpfsOptions(tmpfs *types.ServiceVolumeTmpfs) *mount.TmpfsOptions { } } +func buildImageOptions(image *types.ServiceVolumeImage) *mount.ImageOptions { + if image == nil { + return nil + } + return &mount.ImageOptions{ + Subpath: image.SubPath, + } +} + func (s *composeService) ensureNetwork(ctx context.Context, project *types.Project, name string, n *types.NetworkConfig) (string, error) { if n.External { return s.resolveExternalNetwork(ctx, n) diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index e55a581d6..fbe82afcb 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -290,15 +290,28 @@ func encodedAuth(ref reference.Named, configFile driver.Auth) (string, error) { } func (s *composeService) pullRequiredImages(ctx context.Context, project *types.Project, images map[string]api.ImageSummary, quietPull bool) error { - var needPull []types.ServiceConfig - for _, service := range project.Services { + needPull := map[string]types.ServiceConfig{} + for name, service := range project.Services { pull, err := mustPull(service, images) if err != nil { return err } if pull { - needPull = append(needPull, service) + needPull[name] = service } + for i, vol := range service.Volumes { + if vol.Type == types.VolumeTypeImage { + if _, ok := images[vol.Source]; !ok { + // Hack: create a fake ServiceConfig so we pull missing volume image + n := fmt.Sprintf("%s:volume %d", name, i) + needPull[n] = types.ServiceConfig{ + Name: n, + Image: vol.Source, + } + } + } + } + } if len(needPull) == 0 { return nil @@ -308,11 +321,11 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. w := progress.ContextWriter(ctx) eg, ctx := errgroup.WithContext(ctx) eg.SetLimit(s.maxConcurrency) - pulledImages := make([]api.ImageSummary, len(needPull)) - for i, service := range needPull { + pulledImages := map[string]api.ImageSummary{} + for name, service := range needPull { eg.Go(func() error { id, err := s.pullServiceImage(ctx, service, s.configFile(), w, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"]) - pulledImages[i] = api.ImageSummary{ + pulledImages[name] = api.ImageSummary{ ID: id, Repository: service.Image, LastTagTime: time.Now(), diff --git a/pkg/e2e/fixtures/volumes/compose.yaml b/pkg/e2e/fixtures/volumes/compose.yaml new file mode 100644 index 000000000..4aad0482b --- /dev/null +++ b/pkg/e2e/fixtures/volumes/compose.yaml @@ -0,0 +1,10 @@ +services: + with_image: + image: alpine + command: "ls -al /mnt/image" + volumes: + - type: image + source: nginx:alpine + target: /mnt/image + image: + subpath: usr/share/nginx/html/ \ No newline at end of file diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go index 8ed6b6813..d018ed699 100644 --- a/pkg/e2e/volumes_test.go +++ b/pkg/e2e/volumes_test.go @@ -174,3 +174,22 @@ func TestUpRecreateVolumes_IgnoreBinds(t *testing.T) { res := c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/bind.yaml", "--project-name", projectName, "up", "-d") assert.Check(t, !strings.Contains(res.Combined(), "Recreated")) } + +func TestImageVolume(t *testing.T) { + c := NewCLI(t) + const projectName = "compose-e2e-image-volume" + t.Cleanup(func() { + c.cleanupWithDown(t, projectName) + }) + + version := c.RunDockerCmd(t, "version", "-f", "{{.Server.Version}}") + major, _, found := strings.Cut(version.Combined(), ".") + assert.Assert(t, found) + if major == "26" || major == "27" { + t.Skip("Skipping test due to docker version < 28") + } + + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/volumes/compose.yaml", "--project-name", projectName, "up", "with_image") + out := res.Combined() + assert.Check(t, strings.Contains(out, "index.html")) +}