فهرست منبع

Merge pull request #30597 from dnephin/add-expanded-mount-format-to-stack-deploy

Add expanded mount format to stack deploy
Justin Cormack 8 سال پیش
والد
کامیت
49376cddab

+ 48 - 93
cli/compose/convert/volume.go

@@ -1,21 +1,19 @@
 package convert
 
 import (
-	"fmt"
-	"strings"
-
 	"github.com/docker/docker/api/types/mount"
 	composetypes "github.com/docker/docker/cli/compose/types"
+	"github.com/pkg/errors"
 )
 
 type volumes map[string]composetypes.VolumeConfig
 
 // Volumes from compose-file types to engine api types
-func Volumes(serviceVolumes []string, stackVolumes volumes, namespace Namespace) ([]mount.Mount, error) {
+func Volumes(serviceVolumes []composetypes.ServiceVolumeConfig, stackVolumes volumes, namespace Namespace) ([]mount.Mount, error) {
 	var mounts []mount.Mount
 
-	for _, volumeSpec := range serviceVolumes {
-		mount, err := convertVolumeToMount(volumeSpec, stackVolumes, namespace)
+	for _, volumeConfig := range serviceVolumes {
+		mount, err := convertVolumeToMount(volumeConfig, stackVolumes, namespace)
 		if err != nil {
 			return nil, err
 		}
@@ -24,108 +22,65 @@ func Volumes(serviceVolumes []string, stackVolumes volumes, namespace Namespace)
 	return mounts, nil
 }
 
-func convertVolumeToMount(volumeSpec string, stackVolumes volumes, namespace Namespace) (mount.Mount, error) {
-	var source, target string
-	var mode []string
-
-	// TODO: split Windows path mappings properly
-	parts := strings.SplitN(volumeSpec, ":", 3)
-
-	for _, part := range parts {
-		if strings.TrimSpace(part) == "" {
-			return mount.Mount{}, fmt.Errorf("invalid volume: %s", volumeSpec)
-		}
+func convertVolumeToMount(
+	volume composetypes.ServiceVolumeConfig,
+	stackVolumes volumes,
+	namespace Namespace,
+) (mount.Mount, error) {
+	result := mount.Mount{
+		Type:     mount.Type(volume.Type),
+		Source:   volume.Source,
+		Target:   volume.Target,
+		ReadOnly: volume.ReadOnly,
 	}
 
-	switch len(parts) {
-	case 3:
-		source = parts[0]
-		target = parts[1]
-		mode = strings.Split(parts[2], ",")
-	case 2:
-		source = parts[0]
-		target = parts[1]
-	case 1:
-		target = parts[0]
+	// Anonymous volumes
+	if volume.Source == "" {
+		return result, nil
 	}
-
-	if source == "" {
-		// Anonymous volume
-		return mount.Mount{
-			Type:   mount.TypeVolume,
-			Target: target,
-		}, nil
+	if volume.Type == "volume" && volume.Bind != nil {
+		return result, errors.New("bind options are incompatible with type volume")
+	}
+	if volume.Type == "bind" && volume.Volume != nil {
+		return result, errors.New("volume options are incompatible with type bind")
 	}
 
-	// TODO: catch Windows paths here
-	if strings.HasPrefix(source, "/") {
-		return mount.Mount{
-			Type:        mount.TypeBind,
-			Source:      source,
-			Target:      target,
-			ReadOnly:    isReadOnly(mode),
-			BindOptions: getBindOptions(mode),
-		}, nil
+	if volume.Bind != nil {
+		result.BindOptions = &mount.BindOptions{
+			Propagation: mount.Propagation(volume.Bind.Propagation),
+		}
+	}
+	// Binds volumes
+	if volume.Type == "bind" {
+		return result, nil
 	}
 
-	stackVolume, exists := stackVolumes[source]
+	stackVolume, exists := stackVolumes[volume.Source]
 	if !exists {
-		return mount.Mount{}, fmt.Errorf("undefined volume: %s", source)
+		return result, errors.Errorf("undefined volume: %s", volume.Source)
 	}
 
-	var volumeOptions *mount.VolumeOptions
-	if stackVolume.External.Name != "" {
-		volumeOptions = &mount.VolumeOptions{
-			NoCopy: isNoCopy(mode),
-		}
-		source = stackVolume.External.Name
-	} else {
-		volumeOptions = &mount.VolumeOptions{
-			Labels: AddStackLabel(namespace, stackVolume.Labels),
-			NoCopy: isNoCopy(mode),
-		}
+	result.Source = namespace.Scope(volume.Source)
+	result.VolumeOptions = &mount.VolumeOptions{}
 
-		if stackVolume.Driver != "" {
-			volumeOptions.DriverConfig = &mount.Driver{
-				Name:    stackVolume.Driver,
-				Options: stackVolume.DriverOpts,
-			}
-		}
-		source = namespace.Scope(source)
+	if volume.Volume != nil {
+		result.VolumeOptions.NoCopy = volume.Volume.NoCopy
 	}
-	return mount.Mount{
-		Type:          mount.TypeVolume,
-		Source:        source,
-		Target:        target,
-		ReadOnly:      isReadOnly(mode),
-		VolumeOptions: volumeOptions,
-	}, nil
-}
 
-func modeHas(mode []string, field string) bool {
-	for _, item := range mode {
-		if item == field {
-			return true
-		}
+	// External named volumes
+	if stackVolume.External.External {
+		result.Source = stackVolume.External.Name
+		return result, nil
 	}
-	return false
-}
 
-func isReadOnly(mode []string) bool {
-	return modeHas(mode, "ro")
-}
-
-func isNoCopy(mode []string) bool {
-	return modeHas(mode, "nocopy")
-}
-
-func getBindOptions(mode []string) *mount.BindOptions {
-	for _, item := range mode {
-		for _, propagation := range mount.Propagations {
-			if mount.Propagation(item) == propagation {
-				return &mount.BindOptions{Propagation: mount.Propagation(item)}
-			}
+	result.VolumeOptions.Labels = AddStackLabel(namespace, stackVolume.Labels)
+	if stackVolume.Driver != "" || stackVolume.DriverOpts != nil {
+		result.VolumeOptions.DriverConfig = &mount.Driver{
+			Name:    stackVolume.Driver,
+			Options: stackVolume.DriverOpts,
 		}
 	}
-	return nil
+
+	// Named volumes
+	return result, nil
 }

+ 72 - 39
cli/compose/convert/volume_test.go

@@ -8,51 +8,48 @@ import (
 	"github.com/docker/docker/pkg/testutil/assert"
 )
 
-func TestIsReadOnly(t *testing.T) {
-	assert.Equal(t, isReadOnly([]string{"foo", "bar", "ro"}), true)
-	assert.Equal(t, isReadOnly([]string{"ro"}), true)
-	assert.Equal(t, isReadOnly([]string{}), false)
-	assert.Equal(t, isReadOnly([]string{"foo", "rw"}), false)
-	assert.Equal(t, isReadOnly([]string{"foo"}), false)
-}
-
-func TestIsNoCopy(t *testing.T) {
-	assert.Equal(t, isNoCopy([]string{"foo", "bar", "nocopy"}), true)
-	assert.Equal(t, isNoCopy([]string{"nocopy"}), true)
-	assert.Equal(t, isNoCopy([]string{}), false)
-	assert.Equal(t, isNoCopy([]string{"foo", "rw"}), false)
-}
-
-func TestGetBindOptions(t *testing.T) {
-	opts := getBindOptions([]string{"slave"})
-	expected := mount.BindOptions{Propagation: mount.PropagationSlave}
-	assert.Equal(t, *opts, expected)
-}
-
-func TestGetBindOptionsNone(t *testing.T) {
-	opts := getBindOptions([]string{"ro"})
-	assert.Equal(t, opts, (*mount.BindOptions)(nil))
-}
-
 func TestConvertVolumeToMountAnonymousVolume(t *testing.T) {
-	stackVolumes := volumes{}
-	namespace := NewNamespace("foo")
+	config := composetypes.ServiceVolumeConfig{
+		Type:   "volume",
+		Target: "/foo/bar",
+	}
 	expected := mount.Mount{
 		Type:   mount.TypeVolume,
 		Target: "/foo/bar",
 	}
-	mount, err := convertVolumeToMount("/foo/bar", stackVolumes, namespace)
+	mount, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
 	assert.NilError(t, err)
 	assert.DeepEqual(t, mount, expected)
 }
 
-func TestConvertVolumeToMountInvalidFormat(t *testing.T) {
+func TestConvertVolumeToMountConflictingOptionsBind(t *testing.T) {
 	namespace := NewNamespace("foo")
-	invalids := []string{"::", "::cc", ":bb:", "aa::", "aa::cc", "aa:bb:", " : : ", " : :cc", " :bb: ", "aa: : ", "aa: :cc", "aa:bb: "}
-	for _, vol := range invalids {
-		_, err := convertVolumeToMount(vol, volumes{}, namespace)
-		assert.Error(t, err, "invalid volume: "+vol)
+
+	config := composetypes.ServiceVolumeConfig{
+		Type:   "volume",
+		Source: "foo",
+		Target: "/target",
+		Bind: &composetypes.ServiceVolumeBind{
+			Propagation: "slave",
+		},
 	}
+	_, err := convertVolumeToMount(config, volumes{}, namespace)
+	assert.Error(t, err, "bind options are incompatible")
+}
+
+func TestConvertVolumeToMountConflictingOptionsVolume(t *testing.T) {
+	namespace := NewNamespace("foo")
+
+	config := composetypes.ServiceVolumeConfig{
+		Type:   "bind",
+		Source: "/foo",
+		Target: "/target",
+		Volume: &composetypes.ServiceVolumeVolume{
+			NoCopy: true,
+		},
+	}
+	_, err := convertVolumeToMount(config, volumes{}, namespace)
+	assert.Error(t, err, "volume options are incompatible")
 }
 
 func TestConvertVolumeToMountNamedVolume(t *testing.T) {
@@ -84,9 +81,19 @@ func TestConvertVolumeToMountNamedVolume(t *testing.T) {
 					"opt": "value",
 				},
 			},
+			NoCopy: true,
+		},
+	}
+	config := composetypes.ServiceVolumeConfig{
+		Type:     "volume",
+		Source:   "normal",
+		Target:   "/foo",
+		ReadOnly: true,
+		Volume: &composetypes.ServiceVolumeVolume{
+			NoCopy: true,
 		},
 	}
-	mount, err := convertVolumeToMount("normal:/foo:ro", stackVolumes, namespace)
+	mount, err := convertVolumeToMount(config, stackVolumes, namespace)
 	assert.NilError(t, err)
 	assert.DeepEqual(t, mount, expected)
 }
@@ -109,7 +116,12 @@ func TestConvertVolumeToMountNamedVolumeExternal(t *testing.T) {
 			NoCopy: false,
 		},
 	}
-	mount, err := convertVolumeToMount("outside:/foo", stackVolumes, namespace)
+	config := composetypes.ServiceVolumeConfig{
+		Type:   "volume",
+		Source: "outside",
+		Target: "/foo",
+	}
+	mount, err := convertVolumeToMount(config, stackVolumes, namespace)
 	assert.NilError(t, err)
 	assert.DeepEqual(t, mount, expected)
 }
@@ -132,7 +144,15 @@ func TestConvertVolumeToMountNamedVolumeExternalNoCopy(t *testing.T) {
 			NoCopy: true,
 		},
 	}
-	mount, err := convertVolumeToMount("outside:/foo:nocopy", stackVolumes, namespace)
+	config := composetypes.ServiceVolumeConfig{
+		Type:   "volume",
+		Source: "outside",
+		Target: "/foo",
+		Volume: &composetypes.ServiceVolumeVolume{
+			NoCopy: true,
+		},
+	}
+	mount, err := convertVolumeToMount(config, stackVolumes, namespace)
 	assert.NilError(t, err)
 	assert.DeepEqual(t, mount, expected)
 }
@@ -147,13 +167,26 @@ func TestConvertVolumeToMountBind(t *testing.T) {
 		ReadOnly:    true,
 		BindOptions: &mount.BindOptions{Propagation: mount.PropagationShared},
 	}
-	mount, err := convertVolumeToMount("/bar:/foo:ro,shared", stackVolumes, namespace)
+	config := composetypes.ServiceVolumeConfig{
+		Type:     "bind",
+		Source:   "/bar",
+		Target:   "/foo",
+		ReadOnly: true,
+		Bind:     &composetypes.ServiceVolumeBind{Propagation: "shared"},
+	}
+	mount, err := convertVolumeToMount(config, stackVolumes, namespace)
 	assert.NilError(t, err)
 	assert.DeepEqual(t, mount, expected)
 }
 
 func TestConvertVolumeToMountVolumeDoesNotExist(t *testing.T) {
 	namespace := NewNamespace("foo")
-	_, err := convertVolumeToMount("unknown:/foo:ro", volumes{}, namespace)
+	config := composetypes.ServiceVolumeConfig{
+		Type:     "volume",
+		Source:   "unknown",
+		Target:   "/foo",
+		ReadOnly: true,
+	}
+	_, err := convertVolumeToMount(config, volumes{}, namespace)
 	assert.Error(t, err, "undefined volume: unknown")
 }

+ 22 - 16
cli/compose/loader/loader.go

@@ -251,6 +251,8 @@ func transformHook(
 		return transformMappingOrList(data, "="), nil
 	case reflect.TypeOf(types.MappingWithColon{}):
 		return transformMappingOrList(data, ":"), nil
+	case reflect.TypeOf(types.ServiceVolumeConfig{}):
+		return transformServiceVolumeConfig(data)
 	}
 	return data, nil
 }
@@ -333,10 +335,7 @@ func LoadService(name string, serviceDict types.Dict, workingDir string) (*types
 		return nil, err
 	}
 
-	if err := resolveVolumePaths(serviceConfig.Volumes, workingDir); err != nil {
-		return nil, err
-	}
-
+	resolveVolumePaths(serviceConfig.Volumes, workingDir)
 	return serviceConfig, nil
 }
 
@@ -369,22 +368,15 @@ func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string) e
 	return nil
 }
 
-func resolveVolumePaths(volumes []string, workingDir string) error {
-	for i, mapping := range volumes {
-		parts := strings.SplitN(mapping, ":", 2)
-		if len(parts) == 1 {
+func resolveVolumePaths(volumes []types.ServiceVolumeConfig, workingDir string) {
+	for i, volume := range volumes {
+		if volume.Type != "bind" {
 			continue
 		}
 
-		if strings.HasPrefix(parts[0], ".") {
-			parts[0] = absPath(workingDir, parts[0])
-		}
-		parts[0] = expandUser(parts[0])
-
-		volumes[i] = strings.Join(parts, ":")
+		volume.Source = absPath(workingDir, expandUser(volume.Source))
+		volumes[i] = volume
 	}
-
-	return nil
 }
 
 // TODO: make this more robust
@@ -555,6 +547,20 @@ func transformServiceSecret(data interface{}) (interface{}, error) {
 	}
 }
 
+func transformServiceVolumeConfig(data interface{}) (interface{}, error) {
+	switch value := data.(type) {
+	case string:
+		return parseVolume(value)
+	case types.Dict:
+		return data, nil
+	case map[string]interface{}:
+		return data, nil
+	default:
+		return data, fmt.Errorf("invalid type %T for service volume", value)
+	}
+
+}
+
 func transformServiceNetworkMap(value interface{}) (interface{}, error) {
 	if list, ok := value.([]interface{}); ok {
 		mapValue := map[interface{}]interface{}{}

+ 35 - 7
cli/compose/loader/loader_test.go

@@ -881,13 +881,13 @@ func TestFullExample(t *testing.T) {
 			},
 		},
 		User: "someone",
-		Volumes: []string{
-			"/var/lib/mysql",
-			"/opt/data:/var/lib/mysql",
-			fmt.Sprintf("%s:/code", workingDir),
-			fmt.Sprintf("%s/static:/var/www/html", workingDir),
-			fmt.Sprintf("%s/configs:/etc/configs/:ro", homeDir),
-			"datavolume:/var/lib/mysql",
+		Volumes: []types.ServiceVolumeConfig{
+			{Target: "/var/lib/mysql", Type: "volume"},
+			{Source: "/opt/data", Target: "/var/lib/mysql", Type: "bind"},
+			{Source: workingDir, Target: "/code", Type: "bind"},
+			{Source: workingDir + "/static", Target: "/var/www/html", Type: "bind"},
+			{Source: homeDir + "/configs", Target: "/etc/configs/", Type: "bind", ReadOnly: true},
+			{Source: "datavolume", Target: "/var/lib/mysql", Type: "volume"},
 		},
 		WorkingDir: "/code",
 	}
@@ -1085,3 +1085,31 @@ services:
 	assert.Equal(t, 1, len(config.Services))
 	assert.Equal(t, expected, config.Services[0].Ports)
 }
+
+func TestLoadExpandedMountFormat(t *testing.T) {
+	config, err := loadYAML(`
+version: "3.1"
+services:
+  web:
+    image: busybox
+    volumes:
+      - type: volume
+        source: foo
+        target: /target
+        read_only: true
+volumes:
+  foo: {}
+`)
+	assert.NoError(t, err)
+
+	expected := types.ServiceVolumeConfig{
+		Type:     "volume",
+		Source:   "foo",
+		Target:   "/target",
+		ReadOnly: true,
+	}
+
+	assert.Equal(t, 1, len(config.Services))
+	assert.Equal(t, 1, len(config.Services[0].Volumes))
+	assert.Equal(t, expected, config.Services[0].Volumes[0])
+}

+ 119 - 0
cli/compose/loader/volume.go

@@ -0,0 +1,119 @@
+package loader
+
+import (
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/docker/docker/api/types/mount"
+	"github.com/docker/docker/cli/compose/types"
+	"github.com/pkg/errors"
+)
+
+func parseVolume(spec string) (types.ServiceVolumeConfig, error) {
+	volume := types.ServiceVolumeConfig{}
+
+	switch len(spec) {
+	case 0:
+		return volume, errors.New("invalid empty volume spec")
+	case 1, 2:
+		volume.Target = spec
+		volume.Type = string(mount.TypeVolume)
+		return volume, nil
+	}
+
+	buffer := []rune{}
+	for _, char := range spec {
+		switch {
+		case isWindowsDrive(char, buffer, volume):
+			buffer = append(buffer, char)
+		case char == ':':
+			if err := populateFieldFromBuffer(char, buffer, &volume); err != nil {
+				return volume, errors.Wrapf(err, "invalid spec: %s", spec)
+			}
+			buffer = []rune{}
+		default:
+			buffer = append(buffer, char)
+		}
+	}
+
+	if err := populateFieldFromBuffer(rune(0), buffer, &volume); err != nil {
+		return volume, errors.Wrapf(err, "invalid spec: %s", spec)
+	}
+	populateType(&volume)
+	return volume, nil
+}
+
+func isWindowsDrive(char rune, buffer []rune, volume types.ServiceVolumeConfig) bool {
+	return char == ':' && len(buffer) == 1 && unicode.IsLetter(buffer[0])
+}
+
+func populateFieldFromBuffer(char rune, buffer []rune, volume *types.ServiceVolumeConfig) error {
+	strBuffer := string(buffer)
+	switch {
+	case len(buffer) == 0:
+		return errors.New("empty section between colons")
+	// Anonymous volume
+	case volume.Source == "" && char == rune(0):
+		volume.Target = strBuffer
+		return nil
+	case volume.Source == "":
+		volume.Source = strBuffer
+		return nil
+	case volume.Target == "":
+		volume.Target = strBuffer
+		return nil
+	case char == ':':
+		return errors.New("too many colons")
+	}
+	for _, option := range strings.Split(strBuffer, ",") {
+		switch option {
+		case "ro":
+			volume.ReadOnly = true
+		case "nocopy":
+			volume.Volume = &types.ServiceVolumeVolume{NoCopy: true}
+		default:
+			if isBindOption(option) {
+				volume.Bind = &types.ServiceVolumeBind{Propagation: option}
+			} else {
+				return errors.Errorf("unknown option: %s", option)
+			}
+		}
+	}
+	return nil
+}
+
+func isBindOption(option string) bool {
+	for _, propagation := range mount.Propagations {
+		if mount.Propagation(option) == propagation {
+			return true
+		}
+	}
+	return false
+}
+
+func populateType(volume *types.ServiceVolumeConfig) {
+	switch {
+	// Anonymous volume
+	case volume.Source == "":
+		volume.Type = string(mount.TypeVolume)
+	case isFilePath(volume.Source):
+		volume.Type = string(mount.TypeBind)
+	default:
+		volume.Type = string(mount.TypeVolume)
+	}
+}
+
+func isFilePath(source string) bool {
+	switch source[0] {
+	case '.', '/', '~':
+		return true
+	}
+
+	// Windows absolute path
+	first, next := utf8.DecodeRuneInString(source)
+	if unicode.IsLetter(first) && source[next] == ':' {
+		return true
+	}
+	return false
+}

+ 134 - 0
cli/compose/loader/volume_test.go

@@ -0,0 +1,134 @@
+package loader
+
+import (
+	"testing"
+
+	"github.com/docker/docker/cli/compose/types"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestParseVolumeAnonymousVolume(t *testing.T) {
+	for _, path := range []string{"/path", "/path/foo"} {
+		volume, err := parseVolume(path)
+		expected := types.ServiceVolumeConfig{Type: "volume", Target: path}
+		assert.NilError(t, err)
+		assert.DeepEqual(t, volume, expected)
+	}
+}
+
+func TestParseVolumeAnonymousVolumeWindows(t *testing.T) {
+	for _, path := range []string{"C:\\path", "Z:\\path\\foo"} {
+		volume, err := parseVolume(path)
+		expected := types.ServiceVolumeConfig{Type: "volume", Target: path}
+		assert.NilError(t, err)
+		assert.DeepEqual(t, volume, expected)
+	}
+}
+
+func TestParseVolumeTooManyColons(t *testing.T) {
+	_, err := parseVolume("/foo:/foo:ro:foo")
+	assert.Error(t, err, "too many colons")
+}
+
+func TestParseVolumeShortVolumes(t *testing.T) {
+	for _, path := range []string{".", "/a"} {
+		volume, err := parseVolume(path)
+		expected := types.ServiceVolumeConfig{Type: "volume", Target: path}
+		assert.NilError(t, err)
+		assert.DeepEqual(t, volume, expected)
+	}
+}
+
+func TestParseVolumeMissingSource(t *testing.T) {
+	for _, spec := range []string{":foo", "/foo::ro"} {
+		_, err := parseVolume(spec)
+		assert.Error(t, err, "empty section between colons")
+	}
+}
+
+func TestParseVolumeBindMount(t *testing.T) {
+	for _, path := range []string{"./foo", "~/thing", "../other", "/foo", "/home/user"} {
+		volume, err := parseVolume(path + ":/target")
+		expected := types.ServiceVolumeConfig{
+			Type:   "bind",
+			Source: path,
+			Target: "/target",
+		}
+		assert.NilError(t, err)
+		assert.DeepEqual(t, volume, expected)
+	}
+}
+
+func TestParseVolumeRelativeBindMountWindows(t *testing.T) {
+	for _, path := range []string{
+		"./foo",
+		"~/thing",
+		"../other",
+		"D:\\path", "/home/user",
+	} {
+		volume, err := parseVolume(path + ":d:\\target")
+		expected := types.ServiceVolumeConfig{
+			Type:   "bind",
+			Source: path,
+			Target: "d:\\target",
+		}
+		assert.NilError(t, err)
+		assert.DeepEqual(t, volume, expected)
+	}
+}
+
+func TestParseVolumeWithBindOptions(t *testing.T) {
+	volume, err := parseVolume("/source:/target:slave")
+	expected := types.ServiceVolumeConfig{
+		Type:   "bind",
+		Source: "/source",
+		Target: "/target",
+		Bind:   &types.ServiceVolumeBind{Propagation: "slave"},
+	}
+	assert.NilError(t, err)
+	assert.DeepEqual(t, volume, expected)
+}
+
+func TestParseVolumeWithBindOptionsWindows(t *testing.T) {
+	volume, err := parseVolume("C:\\source\\foo:D:\\target:ro,rprivate")
+	expected := types.ServiceVolumeConfig{
+		Type:     "bind",
+		Source:   "C:\\source\\foo",
+		Target:   "D:\\target",
+		ReadOnly: true,
+		Bind:     &types.ServiceVolumeBind{Propagation: "rprivate"},
+	}
+	assert.NilError(t, err)
+	assert.DeepEqual(t, volume, expected)
+}
+
+func TestParseVolumeWithInvalidVolumeOptions(t *testing.T) {
+	_, err := parseVolume("name:/target:bogus")
+	assert.Error(t, err, "invalid spec: name:/target:bogus: unknown option: bogus")
+}
+
+func TestParseVolumeWithVolumeOptions(t *testing.T) {
+	volume, err := parseVolume("name:/target:nocopy")
+	expected := types.ServiceVolumeConfig{
+		Type:   "volume",
+		Source: "name",
+		Target: "/target",
+		Volume: &types.ServiceVolumeVolume{NoCopy: true},
+	}
+	assert.NilError(t, err)
+	assert.DeepEqual(t, volume, expected)
+}
+
+func TestParseVolumeWithReadOnly(t *testing.T) {
+	for _, path := range []string{"./foo", "/home/user"} {
+		volume, err := parseVolume(path + ":/target:ro")
+		expected := types.ServiceVolumeConfig{
+			Type:     "bind",
+			Source:   path,
+			Target:   "/target",
+			ReadOnly: true,
+		}
+		assert.NilError(t, err)
+		assert.DeepEqual(t, volume, expected)
+	}
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
cli/compose/schema/bindata.go


+ 31 - 1
cli/compose/schema/data/config_schema_v3.1.json

@@ -235,7 +235,37 @@
         },
         "user": {"type": "string"},
         "userns_mode": {"type": "string"},
-        "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "volumes": {
+          "type": "array",
+          "items": {
+            "oneOf": [
+              {"type": "string"},
+              {
+                "type": "object",
+                "required": ["type"],
+                "properties": {
+                  "type": {"type": "string"},
+                  "source": {"type": "string"},
+                  "target": {"type": "string"},
+                  "read_only": {"type": "boolean"},
+                  "bind": {
+                    "type": "object",
+                    "properties": {
+                      "propagation": {"type": "string"}
+                    }
+                  },
+                  "volume": {
+                    "type": "object",
+                    "properties": {
+                      "nocopy": {"type": "boolean"}
+                    }
+                  }
+                }
+              }
+            ],
+            "uniqueItems": true
+          }
+        },
         "working_dir": {"type": "string"}
       },
       "additionalProperties": false

+ 49 - 18
cli/compose/schema/schema.go

@@ -78,18 +78,27 @@ func Validate(config map[string]interface{}, version string) error {
 
 func toError(result *gojsonschema.Result) error {
 	err := getMostSpecificError(result.Errors())
-	description := getDescription(err)
-	return fmt.Errorf("%s %s", err.Field(), description)
+	return err
 }
 
-func getDescription(err gojsonschema.ResultError) string {
-	if err.Type() == "invalid_type" {
-		if expectedType, ok := err.Details()["expected"].(string); ok {
+const (
+	jsonschemaOneOf = "number_one_of"
+	jsonschemaAnyOf = "number_any_of"
+)
+
+func getDescription(err validationError) string {
+	switch err.parent.Type() {
+	case "invalid_type":
+		if expectedType, ok := err.parent.Details()["expected"].(string); ok {
 			return fmt.Sprintf("must be a %s", humanReadableType(expectedType))
 		}
+	case jsonschemaOneOf, jsonschemaAnyOf:
+		if err.child == nil {
+			return err.parent.Description()
+		}
+		return err.child.Description()
 	}
-
-	return err.Description()
+	return err.parent.Description()
 }
 
 func humanReadableType(definition string) string {
@@ -113,23 +122,45 @@ func humanReadableType(definition string) string {
 	return definition
 }
 
-func getMostSpecificError(errors []gojsonschema.ResultError) gojsonschema.ResultError {
-	var mostSpecificError gojsonschema.ResultError
+type validationError struct {
+	parent gojsonschema.ResultError
+	child  gojsonschema.ResultError
+}
 
-	for _, err := range errors {
-		if mostSpecificError == nil {
-			mostSpecificError = err
-		} else if specificity(err) > specificity(mostSpecificError) {
-			mostSpecificError = err
-		} else if specificity(err) == specificity(mostSpecificError) {
+func (err validationError) Error() string {
+	description := getDescription(err)
+	return fmt.Sprintf("%s %s", err.parent.Field(), description)
+}
+
+func getMostSpecificError(errors []gojsonschema.ResultError) validationError {
+	mostSpecificError := 0
+	for i, err := range errors {
+		if specificity(err) > specificity(errors[mostSpecificError]) {
+			mostSpecificError = i
+			continue
+		}
+
+		if specificity(err) == specificity(errors[mostSpecificError]) {
 			// Invalid type errors win in a tie-breaker for most specific field name
-			if err.Type() == "invalid_type" && mostSpecificError.Type() != "invalid_type" {
-				mostSpecificError = err
+			if err.Type() == "invalid_type" && errors[mostSpecificError].Type() != "invalid_type" {
+				mostSpecificError = i
 			}
 		}
 	}
 
-	return mostSpecificError
+	if mostSpecificError+1 == len(errors) {
+		return validationError{parent: errors[mostSpecificError]}
+	}
+
+	switch errors[mostSpecificError].Type() {
+	case "number_one_of", "number_any_of":
+		return validationError{
+			parent: errors[mostSpecificError],
+			child:  errors[mostSpecificError+1],
+		}
+	default:
+		return validationError{parent: errors[mostSpecificError]}
+	}
 }
 
 func specificity(err gojsonschema.ResultError) int {

+ 21 - 1
cli/compose/types/types.go

@@ -119,7 +119,7 @@ type ServiceConfig struct {
 	Tty             bool `mapstructure:"tty"`
 	Ulimits         map[string]*UlimitsConfig
 	User            string
-	Volumes         []string
+	Volumes         []ServiceVolumeConfig
 	WorkingDir      string `mapstructure:"working_dir"`
 }
 
@@ -223,6 +223,26 @@ type ServicePortConfig struct {
 	Protocol  string
 }
 
+// ServiceVolumeConfig are references to a volume used by a service
+type ServiceVolumeConfig struct {
+	Type     string
+	Source   string
+	Target   string
+	ReadOnly bool `mapstructure:"read_only"`
+	Bind     *ServiceVolumeBind
+	Volume   *ServiceVolumeVolume
+}
+
+// ServiceVolumeBind are options for a service volume of type bind
+type ServiceVolumeBind struct {
+	Propagation string
+}
+
+// ServiceVolumeVolume are options for a service volume of type volume
+type ServiceVolumeVolume struct {
+	NoCopy bool `mapstructure:"nocopy"`
+}
+
 // ServiceSecretConfig is the secret configuration for a service
 type ServiceSecretConfig struct {
 	Source string

+ 1 - 1
opts/mount.go

@@ -102,7 +102,7 @@ func (m *MountOpt) Set(value string) error {
 		case "volume-nocopy":
 			volumeOptions().NoCopy, err = strconv.ParseBool(value)
 			if err != nil {
-				return fmt.Errorf("invalid value for populate: %s", value)
+				return fmt.Errorf("invalid value for volume-nocopy: %s", value)
 			}
 		case "volume-label":
 			setValueOnMap(volumeOptions().Labels, value)

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است