Repository navigation
Conversation
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>
docker-agent
left a comment
There was a problem hiding this comment.
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 —
setImageSourcesindexesconfig.Mounts[i]viaimageEntriesfilter index; could panic ifWithImagesResolvedchanges mount count (confidence: weak 45/100)
| 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. |
There was a problem hiding this comment.
[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 |
| } | ||
| for i, m := range service.Mounts { | ||
| if m.Type == types.VolumeTypeImage { | ||
| if _, ok := images[m.Source]; !ok { |
There was a problem hiding this comment.
[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:
| 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 |
| return slices.ContainsFunc(service.Volumes, func(v types.ServiceVolumeConfig) bool { | ||
| return v.Source == volumeKey |
There was a problem hiding this comment.
[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:
| 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 |
| 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) |
There was a problem hiding this comment.
[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 Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
… 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>
docker-agent
left a comment
There was a problem hiding this comment.
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)
| return nil, service, nil | ||
| } | ||
| var imageVolumes map[string]struct{} | ||
| if len(inherit.Mounts) > 0 { |
There was a problem hiding this comment.
[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>
docker-agent
left a comment
There was a problem hiding this comment.
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.goinheritedMounts: theslices.ContainsFuncguard correctly skips theImageInspectcall 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.goaddImageSourcePull: the new helper checks bothimages[source](already local) andscheduled[source](already queued) before inserting intoneedPull, correctly deduplicating across thevolumesandmountsloops — the prior missingscheduledcheck is resolved.pkg/compose/reconcile.gomountsVolume: the addedv.Type == types.VolumeTypeVolume &&guard now mirrors the check on themountsbranch, 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.
What this PR does, in one sentence
Makes Compose run the
mountsservice 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
volumesleaves the implementation latitude on how a volume is mounted: Compose looks at each option to decide between aBindsstring and a Mount API entry (bindRequiresMountAPI,volumeRequiresMountAPI,buildMountOptions…), and a few Mount API options have no place in the model (DriverConfigis aFIXME). The compose-go PR addsmounts, an attribute with the structure ofdocker run --mount, and an opt-in loader step (ConvertVolumesToMounts) that moves tomountsthevolumesthe short syntax can't say. This PR is the consumer side, so the design can be judged on something that runs.The
go.modcarries areplaceto 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
volumesare always sent asBindsstrings andmountsalways 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 /targetmakes 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.mountsis a first-class attribute:bind.create_src(off unless set, unlikecreate_host_path),propagation/recursive,volume.labels/nocopy/subpath, andvolume.driver/driver_opts, whichvolumescan't carry. It takes part wherever Compose reads thevolumesof a service:type: imagesources are pulled and pinned (--lock-image-digestsincluded), named volumes are created before the container and the service is recreated when one is replaced,watchandpublishsee its bind mounts, tracing lists its sources.config,publishandbridgekeep the volumes as declared, since they render or hand over the model;run --volumeentries are converted after the load, and replace a volume of the service at the same target.Behavior changes worth knowing:
up. Services without such volumes, and every service not using the new attribute, keep their hash.bind.propagation,volume.nocopyand 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.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 whileconfigstill renders them as declared). Not covered: the Windows paths ofmounts(compose-go side) and the SELinux relabeling of a bind, which the Mount API can't express.🤖 Generated with Claude Code