diff --git a/cmd/compose/bridge.go b/cmd/compose/bridge.go index be93f3923d..938f36cb7c 100644 --- a/cmd/compose/bridge.go +++ b/cmd/compose/bridge.go @@ -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 } diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 290be9b891..a2dfc9ac84 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -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), diff --git a/cmd/compose/config.go b/cmd/compose/config.go index d41bb2f573..818643b3f4 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -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 @@ -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 @@ -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) @@ -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 } @@ -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 @@ -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())) @@ -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 } @@ -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 diff --git a/cmd/compose/config_test.go b/cmd/compose/config_test.go index e297ccca10..4af66f0ed0 100644 --- a/cmd/compose/config_test.go +++ b/cmd/compose/config_test.go @@ -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, @@ -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) @@ -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}, @@ -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, diff --git a/cmd/compose/publish.go b/cmd/compose/publish.go index d58d5518d7..da5fa9efe4 100644 --- a/cmd/compose/publish.go +++ b/cmd/compose/publish.go @@ -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 } diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 9246566a73..bf3e5dbba2 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -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" @@ -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) } @@ -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 } diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go index 681f526357..8700c862d1 100644 --- a/cmd/compose/run_test.go +++ b/cmd/compose/run_test.go @@ -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) +} diff --git a/go.mod b/go.mod index 6eb562961b..56bd60846b 100644 --- a/go.mod +++ b/go.mod @@ -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. +// 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 diff --git a/go.sum b/go.sum index 21cb80fe06..7be2ccf217 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/confighash/hash.go b/internal/confighash/hash.go index f05c10da5f..4d3576ec80 100644 --- a/internal/confighash/hash.go +++ b/internal/confighash/hash.go @@ -157,6 +157,9 @@ var serviceHashKeyOrder = []string{ // sorted fallback would place it here already, listing it only freezes // the position "umask", + // added with the compose-go `mounts` attribute: absent (hence not hashed) + // from every configuration that doesn't use it, so no existing hash moves + "mounts", } // trimServiceHashFields removes the attributes deliberately excluded from the diff --git a/internal/projectloader/loader.go b/internal/projectloader/loader.go index d767d4eaae..5ffb8d7652 100644 --- a/internal/projectloader/loader.go +++ b/internal/projectloader/loader.go @@ -117,7 +117,12 @@ func (s *Service) buildProjectOptions(options api.ProjectLoadOptions, remoteLoad projectOptionsFns = append(projectOptionsFns, unsupportedAttributesLoadOption(options.OnUnsupportedAttribute)) } - return cli.NewProjectOptions(options.ConfigPaths, append(options.ProjectOptionsFns, projectOptionsFns...)...) + // The volumes that the short syntax can't say are moved to `mounts`, so that the engine has no choice to make + // between the Bind and the Mount API when it creates a container. It comes first, for the caller to override it. + convertVolumes := cli.WithLoadOptions(func(o *loader.Options) { o.ConvertVolumesToMounts = true }) + projectOptionsFns = append([]cli.ProjectOptionsFn{convertVolumes}, append(options.ProjectOptionsFns, projectOptionsFns...)...) + + return cli.NewProjectOptions(options.ConfigPaths, projectOptionsFns...) } // postProcessProject applies post-loading transformations to the project diff --git a/internal/projectloader/loader_test.go b/internal/projectloader/loader_test.go index 2dbba5fbce..386163d2f6 100644 --- a/internal/projectloader/loader_test.go +++ b/internal/projectloader/loader_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/compose-spec/compose-go/v2/cli" + "github.com/compose-spec/compose-go/v2/loader" "github.com/docker/cli/cli/command" "github.com/moby/moby/client" "gotest.tools/v3/assert" @@ -318,3 +319,37 @@ type noBackend struct{} func (noBackend) APIClient() client.APIClient { return nil } func (noBackend) CLI() command.Cli { return nil } + +// the volumes the short syntax can't say are moved to `mounts`, so that the engine has no choice to make between +// the Bind and the Mount API; a caller that renders the model can keep them as they are. +func TestLoadProject_ConvertsVolumesToMounts(t *testing.T) { + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + assert.NilError(t, os.WriteFile(composeFile, []byte(` +name: test-project +services: + app: + image: nginx:latest + volumes: + - /var/data:/data:ro + - type: tmpfs + target: /scratch +`), 0o644)) + + service := New(noBackend{}) + + project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ConfigPaths: []string{composeFile}}) + assert.NilError(t, err) + app := project.Services["app"] + assert.Assert(t, is.Len(app.Volumes, 1)) + assert.Equal(t, app.Volumes[0].Target, "/data") + assert.Assert(t, is.Len(app.Mounts, 1)) + assert.Equal(t, app.Mounts[0].Type, "tmpfs") + assert.Equal(t, app.Mounts[0].Target, "/scratch") + + keep := cli.WithLoadOptions(func(o *loader.Options) { o.ConvertVolumesToMounts = false }) + project, err = service.LoadProject(t.Context(), api.ProjectLoadOptions{ConfigPaths: []string{composeFile}, ProjectOptionsFns: []cli.ProjectOptionsFn{keep}}) + assert.NilError(t, err) + app = project.Services["app"] + assert.Assert(t, is.Len(app.Volumes, 2)) + assert.Assert(t, is.Len(app.Mounts, 0)) +} diff --git a/internal/tracing/attributes.go b/internal/tracing/attributes.go index 0f73baead4..16696c62a2 100644 --- a/internal/tracing/attributes.go +++ b/internal/tracing/attributes.go @@ -129,9 +129,12 @@ func ServiceOptions(service types.ServiceConfig) SpanOptions { } attrs = append(attrs, attribute.StringSlice("service.secrets", secretNames)) - volNames := make([]string, len(service.Volumes)) + volNames := make([]string, 0, len(service.Volumes)+len(service.Mounts)) for i := range service.Volumes { - volNames[i] = service.Volumes[i].Source + volNames = append(volNames, service.Volumes[i].Source) + } + for i := range service.Mounts { + volNames = append(volNames, service.Mounts[i].Source) } attrs = append(attrs, attribute.StringSlice("service.volumes", volNames)) diff --git a/pkg/compose/build.go b/pkg/compose/build.go index e70b16d5e0..40f6215f71 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -188,20 +188,39 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types. func resolveImageVolumes(service *types.ServiceConfig, images map[string]api.ImageSummary, projectName string) { var digests []string - for i, vol := range service.Volumes { - if vol.Type != types.VolumeTypeImage { - continue - } - imgName := vol.Source + // resolve returns the name and the digest of the image a `type: image` mount reads from + resolve := func(source string) (string, api.ImageSummary, bool) { + imgName := source img, ok := images[imgName] if !ok { // check if source is another service in the project - imgName = api.GetImageNameOrDefault(types.ServiceConfig{Name: vol.Source}, projectName) + imgName = api.GetImageNameOrDefault(types.ServiceConfig{Name: source}, projectName) // If we still can't find it, it might be an external image that wasn't pulled yet or doesn't exist if img, ok = images[imgName]; !ok { - continue + return "", api.ImageSummary{}, false } } + return imgName, img, true + } + for i, vol := range service.Mounts { + if vol.Type != types.VolumeTypeImage { + continue + } + imgName, img, ok := resolve(vol.Source) + if !ok { + continue + } + service.Mounts[i].Source = imgName + digests = append(digests, vol.Target+"="+img.ID) + } + for i, vol := range service.Volumes { + if vol.Type != types.VolumeTypeImage { + continue + } + imgName, img, ok := resolve(vol.Source) + if !ok { + continue + } // The daemon only resolves a `type=image` mount Source that is a name/tag // or a top-level image ID, not a per-platform manifest digest (which is // what ImageSummary.ID holds to stay stable across attested rebuilds, see @@ -247,6 +266,11 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ imageNames.Add(volume.Source) } } + for _, m := range s.Mounts { + if m.Type == types.VolumeTypeImage { + imageNames.Add(m.Source) + } + } for _, img := range api.GetDependentImages(s, project.Name) { imageNames.Add(img) } diff --git a/pkg/compose/build_test.go b/pkg/compose/build_test.go index 1dbba84de6..c2ac293504 100644 --- a/pkg/compose/build_test.go +++ b/pkg/compose/build_test.go @@ -161,6 +161,18 @@ func TestResolveImageVolumes(t *testing.T) { assert.Equal(t, service.CustomLabels[api.ImageVolumeDigestLabel], "/data=sha256:content") }) + t.Run("source of a mount is an image name", func(t *testing.T) { + service := types.ServiceConfig{ + Name: "web", ContainerSpec: types.ContainerSpec{ + CustomLabels: types.Labels{}, + Mounts: []types.ServiceMountConfig{{Type: types.VolumeTypeImage, Source: "source", Target: "/data"}}, + }, + } + resolveImageVolumes(&service, images, "p") + assert.Equal(t, service.Mounts[0].Source, "p-source") + assert.Equal(t, service.CustomLabels[api.ImageVolumeDigestLabel], "/data=sha256:built") + }) + t.Run("source is another service resolves to its image name", func(t *testing.T) { service := types.ServiceConfig{ Name: "web", ContainerSpec: types.ContainerSpec{ diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 85b01e346a..46b03fc4a3 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -22,6 +22,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net" "net/netip" "os" @@ -45,6 +46,7 @@ import ( "github.com/docker/compose/v5/internal/confighash" "github.com/docker/compose/v5/internal/naming" "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/utils" ) type createOptions struct { @@ -1011,91 +1013,122 @@ func getDependentServiceFromMode(mode string) string { return "" } +// buildContainerVolumes returns what the container mounts: the `volumes` of the service as `Binds` strings, and its +// `mounts` as mounts of the Mount API, as they are. There is no choice to make between the two: the project is loaded +// with the volumes that the short syntax can't say moved to `mounts`. func (s *composeService) buildContainerVolumes( ctx context.Context, p types.Project, service types.ServiceConfig, inherit *container.Summary, ) ([]string, []mount.Mount, error) { - var mounts []mount.Mount - var binds []string - - mountOptions, err := s.buildContainerMountOptions(ctx, p, service, inherit) + inherited, service, err := s.inheritedMounts(ctx, p, service, inherit) if err != nil { return nil, nil, err } - for _, m := range mountOptions { - switch m.Type { - case mount.TypeBind: - // `Mount` is preferred but does not offer option to created host path if missing - // so `Bind` API is used here with raw volume string - // see https://github.com/moby/moby/issues/43483 - if bind, ok := bindStringForMount(service, m); ok { - binds = append(binds, bind) - continue - } - case mount.TypeVolume: - if bind, ok := volumeBindString(p, service, m); ok { - binds = append(binds, bind) - continue + // only one entry per target: the last one declared prevails, and `mounts` come after `volumes` + mountTargets := utils.Set[string]{} + for _, m := range service.Mounts { + mountTargets.Add(m.Target) + } + volumes := slices.DeleteFunc(lastByTarget(service.Volumes, func(v types.ServiceVolumeConfig) string { return v.Target }), + func(v types.ServiceVolumeConfig) bool { return mountTargets.Has(v.Target) }) + serviceMounts := lastByTarget(service.Mounts, func(m types.ServiceMountConfig) string { return m.Target }) + + var binds []string + var mounts []mount.Mount + targets := utils.Set[string]{} + for _, volume := range volumes { + targets.Add(volume.Target) + if volume.Type == types.VolumeTypeVolume && volume.Source == "" { + // the Binds API has no string for an anonymous volume, `docker run -v /target` makes it a mount + anonymous, err := volume.ToMount() + if err != nil { + return nil, nil, err } - case mount.TypeImage: - err := s.checkImageMountSupported(ctx) + serviceMount, err := buildServiceMount(p, anonymous) if err != nil { return nil, nil, err } + mounts = append(mounts, serviceMount) + continue } - mounts = append(mounts, m) + bind, err := volumeBindString(p, volume) + if err != nil { + return nil, nil, err + } + binds = append(binds, bind) + } + for _, m := range serviceMounts { + serviceMount, err := buildServiceMount(p, m) + if err != nil { + return nil, nil, err + } + if serviceMount.Type == mount.TypeImage { + if err := s.checkImageMountSupported(ctx); err != nil { + return nil, nil, err + } + } + mounts = append(mounts, serviceMount) + targets.Add(serviceMount.Target) } - return binds, mounts, nil -} -// bindStringForMount returns the legacy Bind-API string for a bind mount -// which doesn't require the Mount API -func bindStringForMount(service types.ServiceConfig, m mount.Mount) (string, bool) { - v := findVolumeByTarget(service.Volumes, m.Target) - if v == nil { - return "", false + // what the service declares prevails over what is inherited, and over secrets and configs + secretMounts, err := buildContainerSecretMounts(p, service) + if err != nil { + return nil, nil, err } - if v.Type != types.VolumeTypeBind { - v.Source = m.Source + configMounts, err := buildContainerConfigMounts(p, service) + if err != nil { + return nil, nil, err } - if bindRequiresMountAPI(v.Bind) { - return "", false + for _, group := range [][]mount.Mount{inherited, secretMounts, configMounts} { + for _, m := range group { + if targets.Has(m.Target) { + continue + } + mounts = append(mounts, m) + targets.Add(m.Target) + } } - return toBindString(m.Source, v), true + return binds, mounts, nil } -// volumeBindString returns the legacy Bind-API string for a volume mount -// without advanced options, preferred to preserve backward compatibility -func volumeBindString(p types.Project, service types.ServiceConfig, m mount.Mount) (string, bool) { - v := findVolumeByTarget(service.Volumes, m.Target) - vol := findVolumeByName(p.Volumes, m.Source) - if v == nil || vol == nil { - return "", false +// lastByTarget keeps the last entry declared for each target, in the order of those entries +func lastByTarget[T any](entries []T, target func(T) string) []T { + last := map[string]int{} + for i, e := range entries { + last[target(e)] = i } - if volumeRequiresMountAPI(v.Volume) { - return "", false + kept := make([]T, 0, len(last)) + for i, e := range entries { + if last[target(e)] == i { + kept = append(kept, e) + } } - return toBindString(vol.Name, v), true + return kept } -// checkImageMountSupported verifies the negotiated API version supports image -// mounts. The daemon validates image mounts against the negotiated API -// version from the request path, not the server's own max version. -func (s *composeService) checkImageMountSupported(ctx context.Context) error { - version, err := s.RuntimeAPIVersion(ctx) - if err != nil { - return err +// volumeBindString returns the `Binds` string of a volume with a source. The volume has to be one the short syntax can +// say, as the loader moves the others to `mounts`; the host path of a bind is created by the engine when it is missing. +func volumeBindString(p types.Project, v types.ServiceVolumeConfig) (string, error) { + if !v.IsShortSyntaxExpressible() { + return "", fmt.Errorf("volume %s can't be sent as a bind string: it has to be a mount, was the project loaded without converting its volumes?", v.Target) } - if versions.LessThan(version, apiVersion148) { - return fmt.Errorf("volume with type=image require Docker Engine %s or later", dockerEngineV28) + source := v.Source + switch v.Type { + case types.VolumeTypeBind: + var err error + if source, err = absoluteBindSource(source); err != nil { + return "", err + } + case types.VolumeTypeVolume: + if pVolume, ok := p.Volumes[source]; ok && source != "" { + source = pVolume.Name + } } - return nil -} -func toBindString(name string, v *types.ServiceVolumeConfig) string { access := "rw" if v.ReadOnly { access = "ro" @@ -1110,150 +1143,126 @@ func toBindString(name string, v *types.ServiceVolumeConfig) string { if v.Volume != nil && v.Volume.NoCopy { options = append(options, "nocopy") } - return fmt.Sprintf("%s:%s:%s", name, v.Target, strings.Join(options, ",")) + return fmt.Sprintf("%s:%s:%s", source, v.Target, strings.Join(options, ",")), nil } -func findVolumeByName(volumes types.Volumes, name string) *types.VolumeConfig { - for _, vol := range volumes { - if vol.Name == name { - return &vol - } +// absoluteBindSource makes the source of a bind absolute. It has already been prefixed with the working +// directory when needed, by the compose-go project loader. +func absoluteBindSource(source string) (string, error) { + if filepath.IsAbs(source) || isUnixAbs(source) || isWindowsAbs(source) { + return source, nil } - return nil + return filepath.Abs(source) } -func findVolumeByTarget(volumes []types.ServiceVolumeConfig, target string) *types.ServiceVolumeConfig { - for _, v := range volumes { - if v.Target == target { - return &v - } - } - return nil -} - -// bindRequiresMountAPI check if Bind declaration can be implemented by the plain old Bind API or uses any of the advanced -// options which require use of Mount API -func bindRequiresMountAPI(bind *types.ServiceVolumeBind) bool { - switch { - case bind == nil: - return false - case !bool(bind.CreateHostPath): - return true - case bind.Propagation != "": - return true - case bind.Recursive != "": - return true - default: - return false +// checkImageMountSupported verifies the negotiated API version supports image +// mounts. The daemon validates image mounts against the negotiated API +// version from the request path, not the server's own max version. +func (s *composeService) checkImageMountSupported(ctx context.Context) error { + version, err := s.RuntimeAPIVersion(ctx) + if err != nil { + return err } -} - -// volumeRequiresMountAPI check if Volume declaration can be implemented by the plain old Bind API or uses any of the advanced -// options which require use of Mount API -func volumeRequiresMountAPI(vol *types.ServiceVolumeVolume) bool { - switch { - case vol == nil: - return false - case len(vol.Labels) > 0: - return true - case vol.Subpath != "": - return true - case vol.NoCopy: - return true - default: - return false + if versions.LessThan(version, apiVersion148) { + return fmt.Errorf("volume with type=image require Docker Engine %s or later", dockerEngineV28) } + return nil } -func (s *composeService) buildContainerMountOptions(ctx context.Context, p types.Project, service types.ServiceConfig, inherit *container.Summary) ([]mount.Mount, error) { - mounts := map[string]mount.Mount{} - if inherit != nil { - for _, m := range inherit.Mounts { - if m.Type == "tmpfs" { - continue - } - src := m.Source - if m.Type == "volume" { - src = m.Name - } - - img, err := s.apiClient().ImageInspect(ctx, api.GetImageNameOrDefault(service, p.Name)) - if err != nil { - return nil, err - } - - if img.Config != nil { - if _, ok := img.Config.Volumes[m.Destination]; ok { - // inherit previous container's anonymous volume - mounts[m.Destination] = mount.Mount{ - Type: m.Type, - Source: src, - Target: m.Destination, - ReadOnly: !m.RW, - } - } - } - volumes := []types.ServiceVolumeConfig{} - for _, v := range service.Volumes { - if v.Target != m.Destination || v.Source != "" { - volumes = append(volumes, v) - continue - } - // inherit previous container's anonymous volume - mounts[m.Destination] = mount.Mount{ - Type: m.Type, - Source: src, - Target: m.Destination, - ReadOnly: !m.RW, - } +// bindMountsAsVolumes returns the bind mounts of `mounts` as the volumes they are the equivalent of, for +// what only looks at the host paths a service reads from. +func bindMountsAsVolumes(mounts []types.ServiceMountConfig) []types.ServiceVolumeConfig { + var volumes []types.ServiceVolumeConfig + for _, m := range mounts { + if m.Type != types.VolumeTypeBind { + continue + } + volume := types.ServiceVolumeConfig{ + Type: types.VolumeTypeBind, + Source: m.Source, + Target: m.Target, + ReadOnly: m.ReadOnly, + Consistency: m.Consistency, + Bind: &types.ServiceVolumeBind{}, + } + if m.Bind != nil { + volume.Bind = &types.ServiceVolumeBind{ + Propagation: m.Bind.Propagation, + CreateHostPath: types.OptOut(m.Bind.CreateSrc), + Recursive: m.Bind.Recursive, } - service.Volumes = volumes } + volumes = append(volumes, volume) } - - mounts, err := fillBindMounts(p, service, mounts) - if err != nil { - return nil, err - } - - values := make([]mount.Mount, 0, len(mounts)) - for _, v := range mounts { - values = append(values, v) - } - return values, nil + return volumes } -func fillBindMounts(project types.Project, service types.ServiceConfig, mounts map[string]mount.Mount) (map[string]mount.Mount, error) { - for _, volume := range service.Volumes { - bindMount, err := buildMount(project, volume) +// inheritedMounts returns the volumes of the previous container that the new one inherits, which are the anonymous +// ones declared by the image and the ones the service declares as anonymous, and the service without the latter. +func (s *composeService) inheritedMounts(ctx context.Context, p types.Project, service types.ServiceConfig, inherit *container.Summary) ([]mount.Mount, types.ServiceConfig, error) { + if inherit == nil { + return nil, service, nil + } + var imageVolumes map[string]struct{} + if slices.ContainsFunc(inherit.Mounts, func(m container.MountPoint) bool { return m.Type != "tmpfs" }) { + img, err := s.apiClient().ImageInspect(ctx, api.GetImageNameOrDefault(service, p.Name)) if err != nil { - return nil, err + return nil, service, err + } + if img.Config != nil { + imageVolumes = img.Config.Volumes } - mounts[bindMount.Target] = bindMount - } - - secretMounts, err := buildContainerSecretMounts(project, service) - if err != nil { - return nil, err } - for _, secretMount := range secretMounts { - if _, found := mounts[secretMount.Target]; found { + inherited := map[string]mount.Mount{} + for _, m := range inherit.Mounts { + if m.Type == "tmpfs" { continue } - mounts[secretMount.Target] = secretMount + src := m.Source + if m.Type == "volume" { + src = m.Name + } + previous := mount.Mount{ + Type: m.Type, + Source: src, + Target: m.Destination, + ReadOnly: !m.RW, + } + + if _, ok := imageVolumes[m.Destination]; ok { + inherited[m.Destination] = previous + } + var anonymousVolume, anonymousMount bool + service.Volumes, anonymousVolume = withoutAnonymousVolume(service.Volumes, m.Destination) + service.Mounts, anonymousMount = withoutAnonymousMount(service.Mounts, m.Destination) + if anonymousVolume || anonymousMount { + inherited[m.Destination] = previous + } } + // sorted by target, for a stable result + return slices.SortedFunc(maps.Values(inherited), func(a, b mount.Mount) int { return strings.Compare(a.Target, b.Target) }), service, nil +} - configMounts, err := buildContainerConfigMounts(project, service) - if err != nil { - return nil, err +// withoutAnonymousVolume returns the volumes but for the anonymous volume at the target, and whether there was one +func withoutAnonymousVolume(volumes []types.ServiceVolumeConfig, target string) ([]types.ServiceVolumeConfig, bool) { + kept := make([]types.ServiceVolumeConfig, 0, len(volumes)) + for _, v := range volumes { + if v.Target != target || v.Source != "" { + kept = append(kept, v) + } } - for _, configMount := range configMounts { - if _, found := mounts[configMount.Target]; found { - continue + return kept, len(kept) != len(volumes) +} + +// withoutAnonymousMount returns the mounts but for the anonymous volume at the target, and whether there was one +func withoutAnonymousMount(mounts []types.ServiceMountConfig, target string) ([]types.ServiceMountConfig, bool) { + kept := make([]types.ServiceMountConfig, 0, len(mounts)) + for _, m := range mounts { + if m.Target != target || m.Source != "" || m.Type != types.VolumeTypeVolume { + kept = append(kept, m) } - mounts[configMount.Target] = configMount } - return mounts, nil + return kept, len(kept) != len(mounts) } func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount.Mount, error) { @@ -1284,7 +1293,7 @@ func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount continue } - bindMount, err := buildMount(p, types.ServiceVolumeConfig{ + bindMount, err := buildServiceMount(p, types.ServiceMountConfig{ Type: types.VolumeTypeBind, Source: definedConfig.File, Target: target, @@ -1334,14 +1343,12 @@ func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount logrus.Warnf("secret file %s does not exist", definedSecret.Name) } - mnt, err := buildMount(p, types.ServiceVolumeConfig{ + mnt, err := buildServiceMount(p, types.ServiceMountConfig{ Type: types.VolumeTypeBind, Source: definedSecret.File, Target: target, ReadOnly: true, - Bind: &types.ServiceVolumeBind{ - CreateHostPath: false, - }, + Bind: &types.ServiceMountBind{}, }) if err != nil { return nil, err @@ -1367,107 +1374,73 @@ func isWindowsAbs(p string) bool { return paths.IsWindowsAbs(p) } -func buildMount(project types.Project, volume types.ServiceVolumeConfig) (mount.Mount, error) { - source := volume.Source - switch volume.Type { +// buildServiceMount translates an entry of `mounts`, which has the structure of the Mount API, into a +// mount of the engine. Unlike `volumes`, nothing is left to the implementation: it always goes through +// the Mount API, and the options of the type are carried as they are. +func buildServiceMount(project types.Project, m types.ServiceMountConfig) (mount.Mount, error) { + source := m.Source + switch m.Type { case types.VolumeTypeBind: - if !filepath.IsAbs(source) && !isUnixAbs(source) && !isWindowsAbs(source) { - // volume source has already been prefixed with workdir if required, by compose-go project loader - var err error - source, err = filepath.Abs(source) - if err != nil { - return mount.Mount{}, err - } + var err error + if source, err = absoluteBindSource(source); err != nil { + return mount.Mount{}, err } case types.VolumeTypeVolume: - if volume.Source != "" { - pVolume, ok := project.Volumes[volume.Source] - if ok { - source = pVolume.Name - } + if pVolume, ok := project.Volumes[m.Source]; ok && m.Source != "" { + source = pVolume.Name } } - bind, vol, tmpfs, img := buildMountOptions(volume) - - if bind != nil { - volume.Type = types.VolumeTypeBind - } - - return mount.Mount{ - Type: mount.Type(volume.Type), - Source: source, - Target: volume.Target, - ReadOnly: volume.ReadOnly, - Consistency: mount.Consistency(volume.Consistency), - BindOptions: bind, - VolumeOptions: vol, - TmpfsOptions: tmpfs, - ImageOptions: img, - }, nil -} - -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) + result := mount.Mount{ + Type: mount.Type(m.Type), + Source: source, + Target: m.Target, + ReadOnly: m.ReadOnly, + Consistency: mount.Consistency(m.Consistency), } - 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": - return buildBindOption(volume.Bind), nil, nil, nil - case "volume": - if volume.Volume == nil { - return nil, nil, nil, nil + switch m.Type { + case types.VolumeTypeBind: + if m.Bind != nil { + result.BindOptions = &mount.BindOptions{ + Propagation: mount.Propagation(m.Bind.Propagation), + CreateMountpoint: m.Bind.CreateSrc, + } + switch m.Bind.Recursive { + case "disabled": + result.BindOptions.NonRecursive = true + case "writable": + result.BindOptions.ReadOnlyNonRecursive = true + case "readonly": + result.BindOptions.ReadOnlyForceRecursive = true + } } - return nil, &mount.VolumeOptions{ - NoCopy: volume.Volume.NoCopy, - Subpath: volume.Volume.Subpath, - Labels: volume.Volume.Labels, - // DriverConfig: , // FIXME missing from model ? - }, nil, nil - case "tmpfs": - if volume.Tmpfs == nil { - return nil, nil, nil, nil + case types.VolumeTypeVolume: + if m.Volume != nil { + result.VolumeOptions = &mount.VolumeOptions{ + NoCopy: m.Volume.NoCopy, + Subpath: m.Volume.Subpath, + Labels: m.Volume.Labels, + } + if m.Volume.Driver != "" || len(m.Volume.DriverOpts) > 0 { + result.VolumeOptions.DriverConfig = &mount.Driver{ + Name: m.Volume.Driver, + Options: m.Volume.DriverOpts, + } + } } - return nil, nil, &mount.TmpfsOptions{ - SizeBytes: int64(volume.Tmpfs.Size), - Mode: os.FileMode(volume.Tmpfs.Mode), - }, nil - case "image": - if volume.Image == nil { - return nil, nil, nil, nil + case types.VolumeTypeTmpfs: + if m.Tmpfs != nil { + result.TmpfsOptions = &mount.TmpfsOptions{ + SizeBytes: int64(m.Tmpfs.Size), + Mode: os.FileMode(m.Tmpfs.Mode), + } + } + case types.VolumeTypeImage: + if m.Image != nil { + result.ImageOptions = &mount.ImageOptions{Subpath: m.Image.SubPath} } - return nil, nil, nil, &mount.ImageOptions{Subpath: volume.Image.SubPath} - } - return nil, nil, nil, nil -} - -func buildBindOption(bind *types.ServiceVolumeBind) *mount.BindOptions { - if bind == nil { - return nil - } - bindOptions := &mount.BindOptions{ - Propagation: mount.Propagation(bind.Propagation), - CreateMountpoint: bool(bind.CreateHostPath), - } - switch bind.Recursive { - case "disabled": - bindOptions.NonRecursive = true - case "writable": - bindOptions.ReadOnlyNonRecursive = true - case "readonly": - bindOptions.ReadOnlyForceRecursive = true } - return bindOptions + return result, nil } // createNetwork creates the given (managed) network with its compose labels and diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index 350de6ecfa..c93a6a16d7 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -40,12 +40,12 @@ import ( func TestBuildBindMount(t *testing.T) { project := composetypes.Project{} - volume := composetypes.ServiceVolumeConfig{ + volume := composetypes.ServiceMountConfig{ Type: composetypes.VolumeTypeBind, Source: "", Target: "/data", } - mount, err := buildMount(project, volume) + mount, err := buildServiceMount(project, volume) assert.NilError(t, err) assert.Assert(t, filepath.IsAbs(mount.Source)) _, err = os.Stat(mount.Source) @@ -55,12 +55,12 @@ func TestBuildBindMount(t *testing.T) { func TestBuildNamedPipeMount(t *testing.T) { project := composetypes.Project{} - volume := composetypes.ServiceVolumeConfig{ + volume := composetypes.ServiceMountConfig{ Type: composetypes.VolumeTypeNamedPipe, Source: "\\\\.\\pipe\\docker_engine_windows", Target: "\\\\.\\pipe\\docker_engine", } - mount, err := buildMount(project, volume) + mount, err := buildServiceMount(project, volume) assert.NilError(t, err) assert.Equal(t, mount.Type, mountTypes.TypeNamedPipe) } @@ -74,12 +74,12 @@ func TestBuildVolumeMount(t *testing.T) { }, }), } - volume := composetypes.ServiceVolumeConfig{ + volume := composetypes.ServiceMountConfig{ Type: composetypes.VolumeTypeVolume, Source: "myVolume", Target: "/data", } - mount, err := buildMount(project, volume) + mount, err := buildServiceMount(project, volume) assert.NilError(t, err) assert.Equal(t, mount.Source, "myProject_myVolume") assert.Equal(t, mount.Type, mountTypes.TypeVolume) @@ -103,42 +103,48 @@ func TestPrepareNetworkLabels(t *testing.T) { })) } -func TestBuildContainerMountOptions(t *testing.T) { +func TestBuildContainerVolumesInherit(t *testing.T) { project := composetypes.Project{ Name: "myProject", Services: composetypes.Services{ "myService": { - Name: "myService", ContainerSpec: composetypes.ContainerSpec{Volumes: []composetypes.ServiceVolumeConfig{ - { - Type: composetypes.VolumeTypeVolume, - Target: "/var/myvolume1", - }, - { - Type: composetypes.VolumeTypeVolume, - Target: "/var/myvolume2", - }, - { - Type: composetypes.VolumeTypeVolume, - Source: "myVolume3", - Target: "/var/myvolume3", - Volume: &composetypes.ServiceVolumeVolume{ - Subpath: "etc", + Name: "myService", ContainerSpec: composetypes.ContainerSpec{ + Volumes: []composetypes.ServiceVolumeConfig{ + { + Type: composetypes.VolumeTypeVolume, + Target: "/var/myvolume1", + }, + { + Type: composetypes.VolumeTypeVolume, + Target: "/var/myvolume2", + }, + { + Type: composetypes.VolumeTypeVolume, + Source: "myVolume4", + Target: "/var/myvolume4", }, }, - { - Type: composetypes.VolumeTypeNamedPipe, - Source: "\\\\.\\pipe\\docker_engine_windows", - Target: "\\\\.\\pipe\\docker_engine", + Mounts: []composetypes.ServiceMountConfig{ + { + Type: composetypes.VolumeTypeVolume, + Source: "myVolume3", + Target: "/var/myvolume3", + Volume: &composetypes.ServiceMountVolume{ + Subpath: "etc", + }, + }, + { + Type: composetypes.VolumeTypeNamedPipe, + Source: "\\\\.\\pipe\\docker_engine_windows", + Target: "\\\\.\\pipe\\docker_engine", + }, }, - }}, + }, }, }, Volumes: composetypes.Volumes(map[string]composetypes.VolumeConfig{ - "myVolume1": { - Name: "myProject_myVolume1", - }, - "myVolume2": { - Name: "myProject_myVolume2", + "myVolume4": { + Name: "myProject_myVolume4", }, }), } @@ -147,10 +153,12 @@ func TestBuildContainerMountOptions(t *testing.T) { Mounts: []container.MountPoint{ { Type: composetypes.VolumeTypeVolume, + Name: "previous1", Destination: "/var/myvolume1", }, { Type: composetypes.VolumeTypeVolume, + Name: "previous2", Destination: "/var/myvolume2", }, }, @@ -165,29 +173,33 @@ func TestBuildContainerMountOptions(t *testing.T) { } mock.EXPECT().ImageInspect(gomock.Any(), "myProject-myService").AnyTimes().Return(client.ImageInspectResult{}, nil) - mounts, err := s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) - sort.Slice(mounts, func(i, j int) bool { - return mounts[i].Target < mounts[j].Target - }) - assert.NilError(t, err) - assert.Assert(t, len(mounts) == 4) - assert.Equal(t, mounts[0].Target, "/var/myvolume1") - assert.Equal(t, mounts[1].Target, "/var/myvolume2") - assert.Equal(t, mounts[2].Target, "/var/myvolume3") - assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") - assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") - - mounts, err = s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) - sort.Slice(mounts, func(i, j int) bool { - return mounts[i].Target < mounts[j].Target - }) - assert.NilError(t, err) - assert.Assert(t, len(mounts) == 4) - assert.Equal(t, mounts[0].Target, "/var/myvolume1") - assert.Equal(t, mounts[1].Target, "/var/myvolume2") - assert.Equal(t, mounts[2].Target, "/var/myvolume3") - assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") - assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") + // the volumes are sent as bind strings and the mounts as they are, the anonymous volumes being the previous container's + for range 2 { + binds, mounts, err := s.buildContainerVolumes(t.Context(), project, project.Services["myService"], inherit) + assert.NilError(t, err) + assert.DeepEqual(t, binds, []string{"myProject_myVolume4:/var/myvolume4:rw"}) + sort.Slice(mounts, func(i, j int) bool { + return mounts[i].Target < mounts[j].Target + }) + assert.Assert(t, len(mounts) == 4) + assert.Equal(t, mounts[0].Target, "/var/myvolume1") + assert.Equal(t, mounts[0].Source, "previous1") + assert.Equal(t, mounts[1].Target, "/var/myvolume2") + assert.Equal(t, mounts[1].Source, "previous2") + assert.Equal(t, mounts[2].Target, "/var/myvolume3") + assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") + assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") + } +} + +func TestBuildContainerVolumesRejectsVolumeOfMountAPI(t *testing.T) { + project := composetypes.Project{} + service := composetypes.ServiceConfig{ContainerSpec: composetypes.ContainerSpec{Volumes: []composetypes.ServiceVolumeConfig{ + {Type: composetypes.VolumeTypeTmpfs, Target: "/run", Tmpfs: &composetypes.ServiceVolumeTmpfs{}}, + }}} + + _, _, err := (&composeService{}).buildContainerVolumes(t.Context(), project, service, nil) + assert.ErrorContains(t, err, "can't be sent as a bind string") } func TestDefaultNetworkSettings(t *testing.T) { @@ -503,10 +515,162 @@ volumes: }, }, }, + { + name: "anonymous volume goes through the Mount API, the Binds API has no string for it", + yaml: ` +services: + test: + volumes: + - /data +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "volume", + Target: "/data", + VolumeOptions: &mountTypes.VolumeOptions{}, + }, + }, + }, + { + name: "bind with propagation and named volume with nocopy and SELinux label are bind strings", + yaml: ` +services: + test: + volumes: + - ./data:/data:ro,rshared + - cache:/cache:z,nocopy +volumes: + cache: + name: my_cache +`, + binds: []string{ + filepath.Join(pwd, "data") + ":/data:ro,rshared", + "my_cache:/cache:rw,z,nocopy", + }, + mounts: nil, + }, + { + name: "mounts: bind creating its source", + yaml: ` +services: + test: + mounts: + - type: bind + source: ./data + target: /data + bind: + create_src: true + propagation: rshared +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "bind", + Source: filepath.Join(pwd, "data"), + Target: "/data", + BindOptions: &mountTypes.BindOptions{CreateMountpoint: true, Propagation: "rshared"}, + }, + }, + }, + { + name: "mounts: bind does not create its source unless it says so", + yaml: ` +services: + test: + mounts: + - type: bind + source: ./data + target: /data + read_only: true +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "bind", + Source: filepath.Join(pwd, "data"), + Target: "/data", + ReadOnly: true, + }, + }, + }, + { + name: "mounts: named volume goes through the Mount API", + yaml: ` +services: + test: + mounts: + - type: volume + source: data + target: /data +volumes: + data: + name: my_volume +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "volume", + Source: "my_volume", + Target: "/data", + }, + }, + }, + { + name: "mounts: anonymous volume with driver options", + yaml: ` +services: + test: + mounts: + - type: volume + target: /scratch + volume: + nocopy: true + driver: local + driver_opts: + type: tmpfs + device: tmpfs +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "volume", + Target: "/scratch", + VolumeOptions: &mountTypes.VolumeOptions{ + NoCopy: true, + DriverConfig: &mountTypes.Driver{ + Name: "local", + Options: map[string]string{"type": "tmpfs", "device": "tmpfs"}, + }, + }, + }, + }, + }, + { + name: "mounts: tmpfs", + yaml: ` +services: + test: + mounts: + - type: tmpfs + target: /run + tmpfs: + size: 1048576 +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "tmpfs", + Target: "/run", + TmpfsOptions: &mountTypes.TmpfsOptions{SizeBytes: 1048576}, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p, err := composeloader.LoadWithContext(t.Context(), composetypes.ConfigDetails{ + WorkingDir: pwd, ConfigFiles: []composetypes.ConfigFile{ { Filename: "test", @@ -516,6 +680,7 @@ volumes: }, func(options *composeloader.Options) { options.SkipValidation = true options.SkipConsistencyCheck = true + options.ConvertVolumesToMounts = true }) assert.NilError(t, err) s := &composeService{} @@ -544,3 +709,56 @@ func TestSetBlkio(t *testing.T) { assert.Equal(t, resources.BlkioDeviceWriteBps[0].Rate, uint64(3)) assert.Assert(t, resources.BlkioDeviceWriteIOps == nil) } + +func TestWithoutAnonymousMount(t *testing.T) { + mounts := []composetypes.ServiceMountConfig{ + {Type: composetypes.VolumeTypeVolume, Target: "/anonymous"}, + {Type: composetypes.VolumeTypeVolume, Source: "named", Target: "/named"}, + {Type: composetypes.VolumeTypeTmpfs, Target: "/tmp"}, + } + + kept, found := withoutAnonymousMount(mounts, "/anonymous") + assert.Assert(t, found) + assert.DeepEqual(t, kept, mounts[1:]) + + kept, found = withoutAnonymousMount(mounts, "/named") + assert.Assert(t, !found) + assert.DeepEqual(t, kept, mounts) + + kept, found = withoutAnonymousMount(mounts, "/tmp") + assert.Assert(t, !found) + assert.DeepEqual(t, kept, mounts) +} + +// the last entry declared for a target prevails, `mounts` coming after `volumes`, and what is declared prevails over +// what is inherited and over secrets and configs +func TestBuildContainerVolumesOnePerTarget(t *testing.T) { + project := composetypes.Project{ + Name: "myProject", + Secrets: composetypes.Secrets{ + "s": {File: "/secrets/s"}, + }, + } + service := composetypes.ServiceConfig{Name: "myService", ContainerSpec: composetypes.ContainerSpec{ + Volumes: []composetypes.ServiceVolumeConfig{ + {Type: composetypes.VolumeTypeBind, Source: "/first", Target: "/a"}, + {Type: composetypes.VolumeTypeBind, Source: "/second", Target: "/a"}, + {Type: composetypes.VolumeTypeBind, Source: "/volume", Target: "/b"}, + }, + Mounts: []composetypes.ServiceMountConfig{ + {Type: composetypes.VolumeTypeBind, Source: "/mount", Target: "/b"}, + {Type: composetypes.VolumeTypeBind, Source: "/mount-over-secret", Target: "/run/secrets/s"}, + }, + Secrets: []composetypes.ServiceSecretConfig{{Source: "s"}}, + }} + + binds, mounts, err := (&composeService{}).buildContainerVolumes(t.Context(), project, service, nil) + assert.NilError(t, err) + assert.DeepEqual(t, binds, []string{"/second:/a:rw"}) + targets := map[string]string{} + for _, m := range mounts { + targets[m.Target] = m.Source + } + assert.DeepEqual(t, targets, map[string]string{"/b": "/mount", "/run/secrets/s": "/mount-over-secret"}) + assert.Equal(t, len(mounts), 2) +} diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index 29c9f619da..1470639f5d 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -742,10 +742,10 @@ func (s *composeService) checkForBindMount(project *types.Project) map[string][] } } for serviceName, config := range project.Services { - record(serviceName, config.Volumes) + record(serviceName, append(slices.Clone(config.Volumes), bindMountsAsVolumes(config.Mounts)...)) } for name, job := range project.Jobs { - record(name, job.Volumes) + record(name, append(slices.Clone(job.Volumes), bindMountsAsVolumes(job.Mounts)...)) } return allFindings } diff --git a/pkg/compose/publish_test.go b/pkg/compose/publish_test.go index e60bb9558a..b4add1490b 100644 --- a/pkg/compose/publish_test.go +++ b/pkg/compose/publish_test.go @@ -906,6 +906,27 @@ func Test_checkForBindMount_flagsJobBindMount(t *testing.T) { assert.Equal(t, findings["migrate"][0].Source, "/host/data") } +// checkForBindMount must flag the bind mounts of `mounts` as it does those of `volumes`. +func Test_checkForBindMount_flagsBindMountOfMounts(t *testing.T) { + project := &types.Project{ + Services: types.Services{ + "app": types.ServiceConfig{ + Name: "app", + ContainerSpec: types.ContainerSpec{ + Mounts: []types.ServiceMountConfig{ + {Type: types.VolumeTypeBind, Source: "/host/data", Target: "/data"}, + {Type: types.VolumeTypeVolume, Source: "named", Target: "/named"}, + }, + }, + }, + }, + } + + findings := (&composeService{}).checkForBindMount(project) + assert.Equal(t, len(findings["app"]), 1) + assert.Equal(t, findings["app"][0].Source, "/host/data") +} + // checkForSensitiveData must scan a job's env files, same as a service's. func Test_checkForSensitiveData_jobEnvFile(t *testing.T) { dir := t.TempDir() diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 280c2c2727..1afcfaf5d7 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -415,15 +415,12 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. } 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, - ContainerSpec: types.ContainerSpec{Image: vol.Source}, - } - scheduled[vol.Source] = true - } + addImageSourcePull(needPull, scheduled, images, fmt.Sprintf("%s:volume %d", name, i), vol.Source) + } + } + for i, m := range service.Mounts { + if m.Type == types.VolumeTypeImage { + addImageSourcePull(needPull, scheduled, images, fmt.Sprintf("%s:mount %d", name, i), m.Source) } } } @@ -647,3 +644,17 @@ func toPullProgressEvent(parent string, jm jsonstream.Message, events api.EventP Details: details, }) } + +// addImageSourcePull schedules the pull of the image backing a `type: image` volume or mount, unless it is +// already available or already scheduled +func addImageSourcePull(needPull map[string]types.ServiceConfig, scheduled map[string]bool, images map[string]api.ImageSummary, name string, source string) { + if _, ok := images[source]; ok || scheduled[source] { + return + } + // Hack: create a fake ServiceConfig so we pull the missing image + needPull[name] = types.ServiceConfig{ + Name: name, + ContainerSpec: types.ContainerSpec{Image: source}, + } + scheduled[source] = true +} diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index be50e93947..d72b4d1212 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -646,17 +646,23 @@ func (r *reconciler) servicesUsingVolume(volumeKey string) []string { return volumeConsumers(r.project.Services, volumeKey) } +// mountsVolume tells whether the service mounts the compose volume directly, by `volumes` or by `mounts`. +func mountsVolume(service types.ServiceConfig, volumeKey string) bool { + return slices.ContainsFunc(service.Volumes, func(v types.ServiceVolumeConfig) bool { + return v.Type == types.VolumeTypeVolume && v.Source == volumeKey + }) || slices.ContainsFunc(service.Mounts, func(m types.ServiceMountConfig) bool { + return m.Type == types.VolumeTypeVolume && m.Source == volumeKey + }) +} + // volumeConsumers is servicesUsingVolume over an arbitrary set of services. func volumeConsumers(services types.Services, volumeKey string) []string { inSet := map[string]bool{} // Seed with services that mount the volume directly. for _, key := range slices.Sorted(maps.Keys(services)) { service := services[key] - for _, v := range service.Volumes { - if v.Source == volumeKey { - inSet[service.Name] = true - break - } + if mountsVolume(service, volumeKey) { + inSet[service.Name] = true } } // Grow the set by transitive volumes_from closure until it stabilizes: a @@ -1116,12 +1122,23 @@ func (r *reconciler) hasNetworkMismatch(expected types.ServiceConfig, oc Observe // hasVolumeMismatch checks if the container is missing any expected volume mounts. func (r *reconciler) hasVolumeMismatch(expected types.ServiceConfig, oc ObservedContainer) bool { + sources := make([]string, 0, len(expected.Volumes)+len(expected.Mounts)) for _, vol := range expected.Volumes { - if vol.Type != string(mmount.TypeVolume) || vol.Source == "" { + if vol.Type == string(mmount.TypeVolume) { + sources = append(sources, vol.Source) + } + } + for _, m := range expected.Mounts { + if m.Type == string(mmount.TypeVolume) { + sources = append(sources, m.Source) + } + } + for _, source := range sources { + if source == "" { continue } expectedName := "" - if obs, ok := r.resolvedVolumes[vol.Source]; ok { + if obs, ok := r.resolvedVolumes[source]; ok { expectedName = obs.Name } if expectedName == "" { @@ -1751,6 +1768,13 @@ func (r *reconciler) infrastructureDeps(service types.ServiceConfig) []*PlanNode } } } + for _, m := range service.Mounts { + if m.Type == string(mmount.TypeVolume) && m.Source != "" { + if node, ok := r.volumeNodes[m.Source]; ok { + deps = append(deps, node) + } + } + } for _, depName := range slices.Sorted(maps.Keys(service.DependsOn)) { if node, ok := r.serviceNodes[depName]; ok && node != nil { deps = append(deps, node) diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index d8c0c60036..5b99bfd8ab 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -25,6 +25,7 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/moby/moby/api/types/container" + mmount "github.com/moby/moby/api/types/mount" "github.com/sirupsen/logrus" logrustest "github.com/sirupsen/logrus/hooks/test" "gotest.tools/v3/assert" @@ -2242,3 +2243,16 @@ func runningContainer(t *testing.T, svc types.ServiceConfig, id string) Observed }, } } + +func TestHasVolumeMismatch_Mounts(t *testing.T) { + r := &reconciler{resolvedVolumes: map[string]ObservedVolume{"data": {Name: "myproject_data"}}} + service := types.ServiceConfig{ContainerSpec: types.ContainerSpec{ + Mounts: []types.ServiceMountConfig{{Type: types.VolumeTypeVolume, Source: "data", Target: "/data"}}, + }} + withVolume := func(name string) ObservedContainer { + return ObservedContainer{Summary: container.Summary{Mounts: []container.MountPoint{{Type: mmount.TypeVolume, Name: name}}}} + } + + assert.Assert(t, !r.hasVolumeMismatch(service, withVolume("myproject_data"))) + assert.Assert(t, r.hasVolumeMismatch(service, withVolume("replaced"))) +} diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index f77da9021b..a883d75657 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -282,7 +282,7 @@ func prepareRebuildTriggers(project *types.Project, serviceName string, service func (s *composeService) watchTriggerPaths(ctx context.Context, service types.ServiceConfig, config *types.DevelopConfig, syncer sync.Syncer) ([]string, error) { var paths []string for _, trigger := range config.Watch { - if isSync(trigger) && checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) { + if isSync(trigger) && checkIfPathAlreadyBindMounted(trigger.Path, append(slices.Clone(service.Volumes), bindMountsAsVolumes(service.Mounts)...)) { logrus.Warnf("path '%s' also declared by a bind mount volume, this path won't be monitored!\n", trigger.Path) continue } @@ -790,7 +790,7 @@ func (s *composeService) initialSyncFiles(service types.ServiceConfig, trigger t return initialSyncDirectory(trigger, service, ignore) case mode.IsRegular(): // process file - if !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) { + if !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, append(slices.Clone(service.Volumes), bindMountsAsVolumes(service.Mounts)...)) { return []*sync.PathMapping{{ HostPath: trigger.Path, ContainerPath: trigger.Target, @@ -813,7 +813,7 @@ func initialSyncDirectory(trigger types.Trigger, service types.ServiceConfig, ig // walk starts at the root directory return nil } - if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, service.Volumes) { + if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, append(slices.Clone(service.Volumes), bindMountsAsVolumes(service.Mounts)...)) { // By definition sync ignores bind mounted paths if d.IsDir() { // skip folder diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index a4f3f2c44c..ad83e9ba44 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -507,9 +507,11 @@ func TestWatchTriggerPaths_BindMountedPath(t *testing.T) { func TestCheckIfPathAlreadyBindMounted(t *testing.T) { hostDir := t.TempDir() - volumes := []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeBind, Source: hostDir, Bind: &types.ServiceVolumeBind{}}, - } + volumes := bindMountsAsVolumes([]types.ServiceMountConfig{ + {Type: types.VolumeTypeBind, Source: hostDir, Target: "/app"}, + {Type: types.VolumeTypeVolume, Source: "named", Target: "/named"}, + }) + assert.Equal(t, len(volumes), 1) for _, tt := range []struct { name string diff --git a/pkg/e2e/mounts_test.go b/pkg/e2e/mounts_test.go new file mode 100644 index 0000000000..7ca7ac6055 --- /dev/null +++ b/pkg/e2e/mounts_test.go @@ -0,0 +1,66 @@ +//go:build e2e + +/* + 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 e2e + +import ( + "path/filepath" + "testing" + "time" +) + +func TestMountsBindCreateSrc(t *testing.T) { + s := NewScenario(t, "a bind mount of `mounts` must create its missing host source when it sets create_src") + + s.Step("up mounts the source it had to create", + ComposeCmd("up", "-d", "--wait").Within(60*time.Second), + ServiceState("app", "running"), + BindMountSource("app", "/data", filepath.Join(s.Dir(), "data"))) +} + +func TestMountsBindSourceMustExist(t *testing.T) { + s := NewScenario(t, "a bind mount of `mounts` must not create its host source unless it says so, as `volumes` does") + missing := filepath.Join(s.Dir(), "missing") + + s.Step("up fails on the missing source and leaves the host untouched", + ComposeCmd("up", "-d").MayFail(), + ExitCode(1), + FileAbsent(missing)) +} + +func TestMountsVolumeDriverOpts(t *testing.T) { + s := NewScenario(t, "a volume mount must create its volume with the driver options of the mount") + + s.Step("up mounts an anonymous volume backed by the options of the mount", + ComposeCmd("up", "-d", "--wait").Within(60*time.Second), + ServiceState("app", "running"), + ExecOutputContains("app", "grep ' /scratch ' /proc/mounts", "tmpfs /scratch tmpfs")) +} + +func TestVolumesConvertedToMounts(t *testing.T) { + s := NewScenario(t, "the volumes the short syntax can't say must be mounted through the Mount API, and the model rendered as declared") + + s.Step("config renders the volumes as they are declared", + ComposeCmd("config"), + OutputNotContains("mounts:")). + Step("up mounts the tmpfs, the named volume and the anonymous one", + ComposeCmd("up", "-d", "--wait").Within(60*time.Second), + ServiceState("app", "running"), + ExecOutputContains("app", "grep ' /scratch ' /proc/mounts", "tmpfs /scratch tmpfs"), + ExecOutputContains("app", "grep -c -E ' /(data|anonymous) ' /proc/mounts", "2")) +} diff --git a/pkg/e2e/testdata/TestMountsBindCreateSrc/compose.yaml b/pkg/e2e/testdata/TestMountsBindCreateSrc/compose.yaml new file mode 100644 index 0000000000..468b4b88e0 --- /dev/null +++ b/pkg/e2e/testdata/TestMountsBindCreateSrc/compose.yaml @@ -0,0 +1,10 @@ +services: + app: + image: alpine + command: sleep infinity + mounts: + - type: bind + source: ./data + target: /data + bind: + create_src: true diff --git a/pkg/e2e/testdata/TestMountsBindSourceMustExist/compose.yaml b/pkg/e2e/testdata/TestMountsBindSourceMustExist/compose.yaml new file mode 100644 index 0000000000..207c3d10e1 --- /dev/null +++ b/pkg/e2e/testdata/TestMountsBindSourceMustExist/compose.yaml @@ -0,0 +1,8 @@ +services: + app: + image: alpine + command: sleep infinity + mounts: + - type: bind + source: ./missing + target: /data diff --git a/pkg/e2e/testdata/TestMountsVolumeDriverOpts/compose.yaml b/pkg/e2e/testdata/TestMountsVolumeDriverOpts/compose.yaml new file mode 100644 index 0000000000..2b5b177739 --- /dev/null +++ b/pkg/e2e/testdata/TestMountsVolumeDriverOpts/compose.yaml @@ -0,0 +1,13 @@ +services: + app: + image: alpine + command: sleep infinity + mounts: + - type: volume + target: /scratch + volume: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=16m diff --git a/pkg/e2e/testdata/TestVolumesConvertedToMounts/compose.yaml b/pkg/e2e/testdata/TestVolumesConvertedToMounts/compose.yaml new file mode 100644 index 0000000000..f3df951d22 --- /dev/null +++ b/pkg/e2e/testdata/TestVolumesConvertedToMounts/compose.yaml @@ -0,0 +1,17 @@ +services: + app: + image: alpine + command: sleep infinity + volumes: + - type: tmpfs + target: /scratch + tmpfs: + size: 1048576 + - type: volume + source: data + target: /data + volume: + nocopy: true + - /anonymous +volumes: + data: {}