Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions pkg/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -876,22 +876,13 @@ func (s *composeService) buildContainerVolumes(
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
v := findVolumeByTarget(service.Volumes, m.Target)
if v != nil {
if v.Type != types.VolumeTypeBind {
v.Source = m.Source
}
if !bindRequiresMountAPI(v.Bind) {
source := m.Source
if vol := findVolumeByName(p.Volumes, m.Source); vol != nil {
source = m.Source
}
binds = append(binds, toBindString(source, v))
continue
}
bind, ok, err := buildContainerBindMount(p, service, m)
if err != nil {
return nil, nil, err
}
if ok {
binds = append(binds, bind)
continue
}
case mount.TypeVolume:
v := findVolumeByTarget(service.Volumes, m.Target)
Expand Down Expand Up @@ -937,6 +928,31 @@ func toBindString(name string, v *types.ServiceVolumeConfig) string {
return fmt.Sprintf("%s:%s:%s", name, v.Target, strings.Join(options, ","))
}

func buildContainerBindMount(p types.Project, service types.ServiceConfig, m mount.Mount) (string, bool, error) {
// `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
v := findVolumeByTarget(service.Volumes, m.Target)
if v == nil {
return "", false, nil
}
if v.Type != types.VolumeTypeBind {
v.Source = m.Source
}
if err := validateBindSource(v, m.Source); err != nil {
return "", false, err
}
if bindRequiresMountAPI(v.Bind) {
return "", false, nil
}

source := m.Source
if vol := findVolumeByName(p.Volumes, m.Source); vol != nil {
source = m.Source
}
return toBindString(source, v), true, nil
}

func findVolumeByName(volumes types.Volumes, name string) *types.VolumeConfig {
for _, vol := range volumes {
if vol.Name == name {
Expand All @@ -955,6 +971,23 @@ func findVolumeByTarget(volumes []types.ServiceVolumeConfig, target string) *typ
return nil
}

func validateBindSource(volume *types.ServiceVolumeConfig, source string) error {
if volume.Type != types.VolumeTypeBind || volume.Bind == nil || bool(volume.Bind.CreateHostPath) {
return nil
}
return validateBindSourceExists(source)
}

func validateBindSourceExists(source string) error {
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("invalid mount config for type \"bind\": bind source path does not exist: %s", source)
}
return fmt.Errorf("failed to stat bind source path %s: %w", source, err)
}
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 {
Expand Down
32 changes: 30 additions & 2 deletions pkg/compose/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ services:
test:
volumes:
- type: bind
source: ./data
source: .
target: /data
bind:
create_host_path: false
Expand All @@ -402,7 +402,7 @@ services:
mounts: []mountTypes.Mount{
{
Type: "bind",
Source: filepath.Join(pwd, "data"),
Source: pwd,
Target: "/data",
BindOptions: &mountTypes.BindOptions{CreateMountpoint: false},
},
Expand Down Expand Up @@ -484,3 +484,31 @@ volumes:
})
}
}

func TestBuildContainerVolumesReturnsErrorWhenBindSourceMissingAndCreateHostPathFalse(t *testing.T) {
p, err := composeloader.LoadWithContext(t.Context(), composetypes.ConfigDetails{
ConfigFiles: []composetypes.ConfigFile{
{
Filename: "test",
Content: []byte(`
services:
test:
volumes:
- type: bind
source: ./missing-data
target: /data
bind:
create_host_path: false
`),
},
},
}, func(options *composeloader.Options) {
options.SkipValidation = true
options.SkipConsistencyCheck = true
})
assert.NilError(t, err)

s := &composeService{}
_, _, err = s.buildContainerVolumes(t.Context(), *p, p.Services["test"], nil)
assert.ErrorContains(t, err, "bind source path does not exist")
}
33 changes: 33 additions & 0 deletions pkg/e2e/volumes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package e2e
import (
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
Expand Down Expand Up @@ -120,6 +121,38 @@ func TestProjectVolumeBind(t *testing.T) {
})
}

func TestRunDoesNotCreateMissingBindSourceWhenCreateHostPathIsFalse(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "compose-e2e-bind-create-host-path-false"

projectDir := t.TempDir()
missingSource := filepath.Join(projectDir, "tmp", "missing-dir")
assert.NilError(t, os.MkdirAll(filepath.Dir(missingSource), 0o755))

composeFile := filepath.Join(projectDir, "compose.yaml")
assert.NilError(t, os.WriteFile(composeFile, []byte(`
services:
should_fail:
image: alpine:3.21
command: ["true"]
volumes:
- type: bind
source: ./tmp/missing-dir
target: /mnt/missing
bind:
create_host_path: false
`), 0o600))

t.Cleanup(func() {
c.RunDockerComposeCmdNoCheck(t, "-f", composeFile, "--project-name", projectName, "down", "--remove-orphans", "-v", "--timeout=0")
})

res := c.RunDockerComposeCmdNoCheck(t, "-f", composeFile, "--project-name", projectName, "run", "--rm", "should_fail")
res.Assert(t, icmd.Expected{ExitCode: 1})
assert.Assert(t, strings.Contains(res.Combined(), "bind source path does not exist"), res.Combined())
assert.Assert(t, !checkExists(missingSource), "missing bind source %q should not be created", missingSource)
}

func TestUpSwitchVolumes(t *testing.T) {
c := NewCLI(t)
const projectName = "compose-e2e-switch-volumes"
Expand Down