From fbde4b9a69bbebd7f826a6ce46d2884247fde1b9 Mon Sep 17 00:00:00 2001 From: Scarab Systems Date: Tue, 30 Jun 2026 04:22:35 -0400 Subject: [PATCH] Validate bind source for create_host_path false Return an error before container creation when a bind mount explicitly disables host path creation and the resolved source path is missing. Add unit and end-to-end coverage for the create_host_path:false bind-source contract. Signed-off-by: Scarab Systems --- pkg/compose/create.go | 65 ++++++++++++++++++++++++++++---------- pkg/compose/create_test.go | 32 +++++++++++++++++-- pkg/e2e/volumes_test.go | 33 +++++++++++++++++++ 3 files changed, 112 insertions(+), 18 deletions(-) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 6d15f28f28d..e5bc95b605c 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -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) @@ -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 { @@ -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 { diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index e08e4227dae..b4443b12c35 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -393,7 +393,7 @@ services: test: volumes: - type: bind - source: ./data + source: . target: /data bind: create_host_path: false @@ -402,7 +402,7 @@ services: mounts: []mountTypes.Mount{ { Type: "bind", - Source: filepath.Join(pwd, "data"), + Source: pwd, Target: "/data", BindOptions: &mountTypes.BindOptions{CreateMountpoint: false}, }, @@ -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") +} diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go index d3e5787fa02..112c85ea117 100644 --- a/pkg/e2e/volumes_test.go +++ b/pkg/e2e/volumes_test.go @@ -19,6 +19,7 @@ package e2e import ( "fmt" "net/http" + "os" "path/filepath" "runtime" "strings" @@ -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"