mirror of https://github.com/docker/cli.git
Merge pull request #30597 from dnephin/add-expanded-mount-format-to-stack-deploy
Add expanded mount format to stack deploy
This commit is contained in:
commit
b8c49df008
|
@ -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
|
||||
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,
|
||||
}
|
||||
|
||||
// TODO: split Windows path mappings properly
|
||||
parts := strings.SplitN(volumeSpec, ":", 3)
|
||||
// Anonymous volumes
|
||||
if volume.Source == "" {
|
||||
return result, 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")
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
return mount.Mount{}, fmt.Errorf("invalid volume: %s", volumeSpec)
|
||||
if volume.Bind != nil {
|
||||
result.BindOptions = &mount.BindOptions{
|
||||
Propagation: mount.Propagation(volume.Bind.Propagation),
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
// Binds volumes
|
||||
if volume.Type == "bind" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if source == "" {
|
||||
// Anonymous volume
|
||||
return mount.Mount{
|
||||
Type: mount.TypeVolume,
|
||||
Target: target,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// Named volumes
|
||||
return result, nil
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
mount, err := convertVolumeToMount("normal:/foo:ro", stackVolumes, namespace)
|
||||
config := composetypes.ServiceVolumeConfig{
|
||||
Type: "volume",
|
||||
Source: "normal",
|
||||
Target: "/foo",
|
||||
ReadOnly: true,
|
||||
Volume: &composetypes.ServiceVolumeVolume{
|
||||
NoCopy: true,
|
||||
},
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
|
|
@ -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{}{}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue