Skip to content

[demo] Support the mounts service attribute and drop the Bind/Mount choice (compose-go#979) - #14437

Draft
ndeloof wants to merge 4 commits into
docker:mainfrom
ndeloof:mounts-demo
Draft

ndeloof wants to merge 4 commits into
docker:mainfrom
ndeloof:mounts-demo

Conversation

@ndeloof

@ndeloof ndeloof commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does, in one sentence

Makes Compose run the mounts service attribute proposed in compose-spec/compose-go#979, and uses it to drop from Compose any choice between the Bind and the Mount API when creating a container; it is a draft demo, not meant to be merged as is.

Context

Today volumes leaves the implementation latitude on how a volume is mounted: Compose looks at each option to decide between a Binds string and a Mount API entry (bindRequiresMountAPI, volumeRequiresMountAPI, buildMountOptions…), and a few Mount API options have no place in the model (DriverConfig is a FIXME). The compose-go PR adds mounts, an attribute with the structure of docker run --mount, and an opt-in loader step (ConvertVolumesToMounts) that moves to mounts the volumes the short syntax can't say. This PR is the consumer side, so the design can be judged on something that runs.

The go.mod carries a replace to the head of that PR (ndeloof/compose-go), which is why this stays a draft: it goes away once compose-go releases the attribute.

What the PR brings

  • No more choice at creation. Projects are loaded with the conversion on, so volumes are always sent as Binds strings and mounts always through the Mount API, as declared. A volume that isn't a bind string is refused with an explicit error rather than guessed. The one exception is the anonymous volume: the Binds API has no string for it (docker run -v /target makes it a mount), so it is sent as a mount. Secrets, configs and the volumes inherited from the previous container keep their precedence (declared > inherited > secrets/configs), and there is one entry per target.
  • mounts is a first-class attribute: bind.create_src (off unless set, unlike create_host_path), propagation/recursive, volume.labels/nocopy/subpath, and volume.driver/driver_opts, which volumes can't carry. It takes part wherever Compose reads the volumes of a service: type: image sources are pulled and pinned (--lock-image-digests included), named volumes are created before the container and the service is recreated when one is replaced, watch and publish see its bind mounts, tracing lists its sources.
  • config, publish and bridge keep the volumes as declared, since they render or hand over the model; run --volume entries are converted after the load, and replace a volume of the service at the same target.

Behavior changes worth knowing:

  • The config hash of a service that had a volume the short syntax can't say moves once, since that volume became a mount, so its containers are recreated on the next up. Services without such volumes, and every service not using the new attribute, keep their hash.
  • bind.propagation, volume.nocopy and SELinux options go through the bind string again, as the short syntax says them. The Linux CI of the closed fix(volumes): send bind.propagation and volume.nocopy as a bind string #14428 showed the engine treats both APIs the same for them.
  • The Binds string of a volume goes out in the order declared; mounts too (the map-based order was random).

Covered by unit tests, and four e2e scenarios I ran against a local engine (create_src, a missing source that must still fail without it, driver options on an anonymous volume, and volumes converted at load while config still renders them as declared). Not covered: the Windows paths of mounts (compose-go side) and the SELinux relabeling of a bind, which the Mount API can't express.

🤖 Generated with Claude Code

Replace compose-go with ndeloof/compose-go at the head of
compose-spec/compose-go#979 so that the next commit
can exercise the `mounts` service attribute. To be dropped once that PR is
released.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Mount the entries of `mounts` through the Mount API, as they are declared:
bind `create_src`, volume `driver`/`driver_opts`, tmpfs and image options.
They also take part wherever the `volumes` of a service are read: image
pull and resolution (including --lock-image-digests), volume creation order
and recreation, anonymous volume inheritance, watch's bind mount detection,
the publish bind mount warning and tracing. `mounts` joins the service
config-hash key order.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof requested a review from docker-agent November 2, 2026 12:18

@docker-agent docker-agent left a comment

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.

Assessment: 🔴 CRITICAL

The PR adds mounts service attribute support to Docker Compose. The implementation is largely consistent and mirrors existing volumes patterns. However, several logic issues were introduced in the new code, including one blocker.

Lower-confidence findings (not posted inline)

  • [medium] cmd/compose/config.go:489 — setImageSources indexes config.Mounts[i] via imageEntries filter index; could panic if WithImagesResolved changes mount count (confidence: weak 45/100)

Comment thread go.mod
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

Comment thread pkg/compose/pull.go Outdated
}
for i, m := range service.Mounts {
if m.Type == types.VolumeTypeImage {
if _, ok := images[m.Source]; !ok {

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.

[medium] pullRequiredImages: mounts loop doesn't check scheduled before adding image-type mounts, causing duplicate pulls

The volumes loop sets scheduled[vol.Source] = true after inserting into needPull, and the function comment says scheduled exists to avoid pulling the same image twice. The new mounts loop, however, only checks images[m.Source] (local availability) — it never consults scheduled:

for i, m := range service.Mounts {
    if m.Type == types.VolumeTypeImage {
        if _, ok := images[m.Source]; !ok {   // ← never checks scheduled[m.Source]
            n := fmt.Sprintf("%s:mount %d", name, i)
            needPull[n] = types.ServiceConfig{ ... Image: m.Source ... }
            scheduled[m.Source] = true
        }
    }
}

If the same image source appears in both a volumes entry (already inserted as "svc:volume 0") and a mounts entry, both "svc:volume 0" and "svc:mount 0" are added to needPull with different keys — both pointing to the same image. Both entries are then passed to pullServiceImage, pulling the image twice and wasting bandwidth.

Fix: add a scheduled check before the images check, mirroring the hook-image dedup already done in addPreStartHookPulls:

Suggested change
if _, ok := images[m.Source]; !ok {
if m.Type == types.VolumeTypeImage {
if _, ok := images[m.Source]; !ok && !scheduled[m.Source] {
n := fmt.Sprintf("%s:mount %d", name, i)
Confidence Score
🟢 strong 97/100

Comment thread pkg/compose/reconcile.go Outdated
Comment on lines +650 to +651
return slices.ContainsFunc(service.Volumes, func(v types.ServiceVolumeConfig) bool {
return v.Source == volumeKey

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.

[medium] mountsVolume: service.Volumes predicate missing type filter, causing false positives for bind/image/tmpfs mounts

The new mountsVolume function checks whether a service mounts a named compose volume. The service.Mounts side correctly guards with m.Type == types.VolumeTypeVolume:

func mountsVolume(service types.ServiceConfig, volumeKey string) bool {
    return slices.ContainsFunc(service.Volumes, func(v types.ServiceVolumeConfig) bool {
        return v.Source == volumeKey   // ← no type guard; any volume type matches
    }) || slices.ContainsFunc(service.Mounts, func(m types.ServiceMountConfig) bool {
        return m.Type == types.VolumeTypeVolume && m.Source == volumeKey  // ← correctly guarded
    })
}

A bind-mount whose source path happens to equal a named compose volume key (e.g. a service with source: mydata as a bind path and a project volume named mydata) would make mountsVolume return true. Downstream, volumeConsumers / servicesUsingVolume would include the service in the set of consumers, leading to spurious dependency edges: Compose would wait for or recreate the service when the named volume is replaced, even though the service never actually uses that volume.

Fix: add v.Type == types.VolumeTypeVolume to the predicate, matching the guard on the mounts side:

Suggested change
return slices.ContainsFunc(service.Volumes, func(v types.ServiceVolumeConfig) bool {
return v.Source == volumeKey
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 {
Confidence Score
🟢 strong 100/100

Comment thread pkg/compose/create.go Outdated
if !filepath.IsAbs(source) && !isUnixAbs(source) && !isWindowsAbs(source) {
// source has already been prefixed with workdir if required, by compose-go project loader
var err error
source, err = filepath.Abs(source)

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.

[low] buildServiceMount: empty bind-mount source silently resolves to process CWD via filepath.Abs("")

When m.Type == VolumeTypeBind and m.Source is empty (e.g. a misconfigured or compose-go-unprocessed bind mount), the guard !filepath.IsAbs(source) is true for "", so filepath.Abs("") is called. filepath.Abs treats an empty string as . and returns the process's current working directory. The error is nil, so execution continues — the Docker daemon receives a bind mount of CWD rather than rejecting the request, which is surprising and potentially dangerous behavior.

The comment notes compose-go prefixes relative paths before this function runs, so in practice an empty source may not occur. A defensive guard would be safer:

case types.VolumeTypeBind:
    if source == "" {
        return mount.Mount{}, fmt.Errorf("bind mount has no source")
    }
    if !filepath.IsAbs(source) && !isUnixAbs(source) && !isWindowsAbs(source) {
        ...
    }
Confidence Score
🟡 moderate 67/100

@codecov

codecov Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

… the Bind/Mount choice

The project is now loaded with compose-go's ConvertVolumesToMounts: the
volumes that can't be written as a bind string move to `mounts`. Creating a
container no longer picks an API per option: `volumes` are sent as Binds
strings and `mounts` as mounts of the Mount API, as they are declared. The
only volume without a bind string is the anonymous one, which `docker run -v`
makes a mount too.

`config`, `publish` and `bridge` keep the volumes as declared, since they
render or hand over the model. The volumes of `run --volume` are converted
after the load.

The config hash of a service that had such volumes moves once: its volumes
became mounts.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof ndeloof changed the title [demo] Support the mounts service attribute (compose-go#979) [demo] Support the mounts service attribute and drop the Bind/Mount choice (compose-go#979) Nov 2, 2026
@ndeloof
ndeloof requested a review from docker-agent November 2, 2026 12:39

@docker-agent docker-agent left a comment

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.

This review covers only the commits pushed since 07c8cba28ca0.

Assessment: 🟡 NEEDS ATTENTION

Lower-confidence findings (not posted inline)

  • [medium] cmd/compose/run.go:131 — loader.ConvertVolumesToMounts(project) is called on the entire project, not just the modified target service (confidence: weak 52/100)

Comment thread pkg/compose/create.go Outdated
return nil, service, nil
}
var imageVolumes map[string]struct{}
if len(inherit.Mounts) > 0 {

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.

[medium] inheritedMounts: ImageInspect is now called when all inherited mounts are tmpfs, a regression from the previous behavior

In the removed buildContainerMountOptions, ImageInspect was called inside the per-mount loop, after the if m.Type == "tmpfs" { continue } guard. If every mount of the inherited container was a tmpfs, the loop's body never executed, so ImageInspect was never called.

In the new inheritedMounts, ImageInspect is hoisted outside the loop and is guarded only by len(inherit.Mounts) > 0:

if len(inherit.Mounts) > 0 {   // true even when all mounts are tmpfs
    img, err := s.apiClient().ImageInspect(...)
    if err != nil {
        return nil, service, err  // now fails for all-tmpfs containers
    }

A container whose only mounts are tmpfs now triggers an ImageInspect call that previously never happened. If the underlying image has been deleted (but the container still exists — a valid Docker state, e.g. after docker image prune while a stopped container survives), the new code returns an error while the old code succeeded.

The fix is to check whether any non-tmpfs mount exists before calling ImageInspect:

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))
    ...
}
Confidence Score
🟢 strong 97/100

Pull an image backing several volumes or mounts only once, count only
named volumes as consumers of a compose volume, and skip the image
inspection when a container inherits nothing but tmpfs mounts.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof requested a review from docker-agent November 2, 2026 12:52

@docker-agent docker-agent left a comment

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.

This review covers only the commits pushed since 5e4a7b63cdb0.

Assessment: 🟢 APPROVE

The three targeted fixes in this incremental push all look correct:

  • pkg/compose/create.go inheritedMounts: the slices.ContainsFunc guard correctly skips the ImageInspect call when all inherited mounts are tmpfs, using the same "tmpfs" literal already present in the loop body — the prior regression is resolved.
  • pkg/compose/pull.go addImageSourcePull: the new helper checks both images[source] (already local) and scheduled[source] (already queued) before inserting into needPull, correctly deduplicating across the volumes and mounts loops — the prior missing scheduled check is resolved.
  • pkg/compose/reconcile.go mountsVolume: the added v.Type == types.VolumeTypeVolume && guard now mirrors the check on the mounts branch, eliminating the false-positive for bind-mount sources whose path matches a named volume key — the prior type-filter omission is resolved.

No new issues were introduced.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants