Skip to content

Add a mounts attribute and make the short syntax the canonical form of volumes - #979

Open
ndeloof wants to merge 4 commits into
compose-spec:mainfrom
ndeloof:mounts-attribute
Open

ndeloof wants to merge 4 commits into
compose-spec:mainfrom
ndeloof:mounts-attribute

Conversation

@ndeloof

@ndeloof ndeloof commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #986 (its commit comes first); review the other three.

What this PR does, in one sentence

Adds a mounts attribute, structured like docker run --mount, makes the short syntax the canonical form of volumes, and moves what the short syntax can't say to mounts.

Context

volumes has two syntaxes that don't say the same things. The short syntax ([SOURCE:]TARGET[:OPTIONS]) is what docker run -v reads, so it is bound by its limits: no colon in a path, no subpath, no volume labels, no bind.create_host_path: false, no tmpfs or image mount. The long syntax goes beyond, but leaves it to the implementation to choose how the mount is made (bind string or Mount API), and the two are not strictly interchangeable. The result is that the same volumes entry can mean slightly different things depending on what is written next to it.

What the PR brings

  • mounts, on services, jobs and pre_start hooks: structured YAML only (no short or CSV syntax, since --mount reads , as a separator), with the options of the long syntax of volumes, plus volume.driver / volume.driver_opts, and bind.create_src (off unless set, like bind-create-src). Entries are merged and made unique by target, relative paths are resolved, values are interpolated, and a target can't be declared by both volumes and mounts. Hooks don't inherit mounts.
  • A way to tell what the short syntax can say: format.IsShortSyntaxExpressible, proposed on its own in format: tell whether a volume can be said in the short syntax #986 (the first commit of this branch is that PR, and goes away once it is merged). It is defined by a round trip (the volume rendered in the short syntax must parse back as the same volume), so it can't drift from the parser. This PR also makes it a method, ServiceVolumeConfig.IsShortSyntaxExpressible(), which the canonical form below needs: for that, the volume parser moves to types; format.ParseVolume, format.Propagations and the format functions of format: tell whether a volume can be said in the short syntax #986 are unchanged.
  • An opt-in conversion: loader.Options.ConvertVolumesToMounts (and loader.ConvertVolumesToMounts(project)) moves the entries of volumes that the short syntax can't say to mounts. It is idempotent, and fails with an explicit error for bind.selinux on such an entry, which a mount has no equivalent for.
  • The short syntax is the canonical form of volumes: an entry that the short syntax can say is marshalled to YAML as a string (/data:/t:ro, cache:/t:nocopy), whatever the syntax it was declared with, and the long syntax is deprecated in the schema in favor of mounts. Existing files load as before.

Guardrails: the JSON form of a volume is left structured, because it is what Compose computes the config hash of a service from, and what tools read the model from; the canonical form changes the YAML only (the golden JSON is unchanged). Without ConvertVolumesToMounts, an entry that the short syntax can't say stays in volumes, in the long syntax. The loaded model of existing files is unchanged.

Not settled, and the reason for the draft: the spelling of bind.create_src, whether type has to stay required in a mount (--mount defaults to a volume), whether bind.propagation and volume.nocopy should be exceptions to the rule since the short syntax can say them, and whether ConvertVolumesToMounts should become the default. The matching change to the specification is still to be proposed.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved mount-handling defects can discard job and hook volume settings, bypass validation and image resolution, mishandle paths, and change configuration hashes during round trips.

8 open issues
2 files not reviewed
  • types/container_spec_attributes.gen.go: Generated file
  • types/derived.gen.go: Generated file
What changed in this PR

This PR adds structured mounts to compose-go’s container model and makes short syntax the canonical YAML representation for volumes that can be expressed losslessly.

Changes:

  • Adds mount types and integrates them with loading, interpolation, path resolution, merging, and validation.
  • Moves volume parsing into types and adds opt-in conversion from volumes to mounts.
  • Adds tests and updates YAML expectations while retaining structured JSON serialization.
File Description
types/​volume_spec.go Relocates the short-syntax volume parser into types.
types/​volume_spec_test.go Moves parser tests alongside the implementation.
types/​volume_spec_fuzz_test.go Moves volume parser fuzz coverage.
types/​volume_short.go Adds expressibility checks and canonical YAML serialization.
types/​volume_short_test.go Tests short syntax and serialization.
types/​types.go Adds mounts to the shared container specification.
types/​project.go Integrates mounts into resource pruning and image resolution.
types/​project_test.go Tests mount resource retention and image resolution.
types/​mounts.go Defines mount types and volume-to-mount conversion.
types/​mounts_test.go Tests conversion and unsupported SELinux options.
types/​derived.gen.go Adds generated deep-copy support for mounts.
types/​container_spec_attributes.gen.go Registers the mounts attribute.
transform/​volume.go Adds mount transformation and shares tmpfs mode parsing.
transform/​canonical.go Registers mount canonicalization.
schema/​compose-spec.json Defines mounts and deprecates long volume syntax.
paths/​resolve.go Registers bind-source resolution for mounts.
override/​uncity.go Adds target-based mount deduplication.
override/​merge.go Registers mount volume-label merging.
override/​merge_service_mounts_test.go Tests mount override behavior.
loader/​validate.go Adds mount reference and target-conflict checks.
loader/​tests/​volumes_test.go Tests canonical volume YAML and round trips.
loader/​tests/​mounts_test.go Adds mount loading and validation coverage.
loader/​tests/​mounts_convert_test.go Tests optional conversion and idempotence.
loader/​tests/​completeness_test.go Registers mount conformance coverage.
loader/​testdata/​golden/​web-stack.golden.yaml Updates expected canonical volume YAML.
loader/​normalize.go Cleans mount targets and excludes mounts from hook inheritance.
loader/​mounts.go Implements project-wide optional volume conversion.
loader/​loader.go Adds and invokes the conversion option.
loader/​interpolate.go Registers mount boolean interpolation casts.
format/​volume.go Preserves the existing parser API through delegation.

Review effort: Balanced

Comment thread loader/loader.go Resolved
Comment thread loader/validate.go Resolved
Comment thread override/uncity.go Resolved
Comment thread paths/resolve.go Resolved
Comment thread types/project.go Resolved
Comment thread types/project.go Resolved
Comment thread types/volume_short.go Resolved
Comment thread types/volume_short.go Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Volume short-form serialization still changes service JSON/hash on YAML round trips by dropping empty volume blocks or changing create_host_path from false to true.

1 open issue
8 resolved since last review
2 files not reviewed
  • types/container_spec_attributes.gen.go: Generated file
  • types/derived.gen.go: Generated file

Review effort: Balanced

Comment thread types/volume_short.go
Comment on lines +154 to +163
func (v ServiceVolumeConfig) readsBackWithItsBlocks() bool {
switch v.Type {
case VolumeTypeVolume:
hasBindOptions := v.Bind != nil && (v.Bind.SELinux != "" || v.Bind.Propagation != "")
return v.Volume != nil && (v.Bind != nil) == hasBindOptions
case VolumeTypeBind:
return v.Bind != nil && v.Volume == nil
}
return false
}
ShortSyntax renders a volume as `[SOURCE:]TARGET[:OPTIONS]` when some
spelling of it parses back as the very same volume, and
IsShortSyntaxExpressible reports it. A volume the short syntax has no place
for (tmpfs, image, subpath, labels, consistency, extensions, a bind that
doesn't create its host path or is recursive...) or would read otherwise
(a colon in a path, a bind source that is not a path, a one letter volume
name read as a drive) is not expressible, and can only be written in the
long syntax.

It lets an implementation decide, without guessing from each option, which
volumes it can hand over as a plain string (the `Binds` of the Docker API)
and which need more.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
`mounts` lists the mounts of a container (service, job, pre_start hook) with
the structure of `docker run --mount`: unlike an entry of `volumes`, the
implementation has no latitude on how a mount is made. It is the structure of
the long syntax of `volumes`, but for three things: `bind.create_src` is off
unless set, as `bind-create-src` is, where `create_host_path` is on unless
set; `bind.selinux` is missing, which the flag has no equivalent for; and
`volume.driver` and `volume.driver_opts` exist, as `volume-driver` and
`volume-opt` do. There is no short syntax, which would bring back the
ambiguity of a colon or a comma in a path.

Mounts are canonicalized (tmpfs mode), have their bind source resolved against
the project directory, their target cleaned, are merged by target as volumes
are, are interpolated, and their target and volume are checked together with
the ones of volumes. Image mounts have their image resolved, and a volume
used by a mount is not an unnecessary resource. A pre_start hook doesn't
inherit the mounts of its service, as it doesn't inherit its volumes.

ServiceVolumeConfig.ToMount returns the mount that does what a volume does.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Options.ConvertVolumesToMounts moves, once the model is loaded, the volumes of
services, jobs and pre_start hooks that the short syntax can't say to their
mounts, as the mounts they are equivalent to. The volumes it can say stay, so
that the step is idempotent, and what `config` prints loads as the same model.

A volume that can't become a mount, because it sets the SELinux relabeling of
a bind mount, is refused with the name of the service and of the volume
rather than silently dropped. ConvertVolumesToMounts is exported for what adds
volumes to a project after it was loaded.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…long one

A volume that the short syntax can say is now marshalled to YAML as a
string (`/data:/t:ro`, `cache:/t:nocopy`), whatever the syntax it was
declared with, and only the others keep the long syntax. With the volumes
the short syntax can't say moved to `mounts`, the long syntax of `volumes`
is left to what a mount does better: the schema marks it deprecated.

What is written reads back as the same volume, which is checked by parsing
the string back. The JSON form is left as it was: it is what the hash of a
service is computed from, and what tools read the model from.

The volume parser has to be known to `types` for that, so it moves there
with the short syntax check; `format.ParseVolume` and `format.Propagations`
stay as they were.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
ndeloof added a commit to ndeloof/compose that referenced this pull request Nov 4, 2026
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>
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