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
|
package convert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
composetypes "github.com/docker/docker/cli/compose/types"
|
composetypes "github.com/docker/docker/cli/compose/types"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type volumes map[string]composetypes.VolumeConfig
|
type volumes map[string]composetypes.VolumeConfig
|
||||||
|
|
||||||
// Volumes from compose-file types to engine api types
|
// 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
|
var mounts []mount.Mount
|
||||||
|
|
||||||
for _, volumeSpec := range serviceVolumes {
|
for _, volumeConfig := range serviceVolumes {
|
||||||
mount, err := convertVolumeToMount(volumeSpec, stackVolumes, namespace)
|
mount, err := convertVolumeToMount(volumeConfig, stackVolumes, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -24,108 +22,65 @@ func Volumes(serviceVolumes []string, stackVolumes volumes, namespace Namespace)
|
||||||
return mounts, nil
|
return mounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertVolumeToMount(volumeSpec string, stackVolumes volumes, namespace Namespace) (mount.Mount, error) {
|
func convertVolumeToMount(
|
||||||
var source, target string
|
volume composetypes.ServiceVolumeConfig,
|
||||||
var mode []string
|
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
|
// Anonymous volumes
|
||||||
parts := strings.SplitN(volumeSpec, ":", 3)
|
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 volume.Bind != nil {
|
||||||
if strings.TrimSpace(part) == "" {
|
result.BindOptions = &mount.BindOptions{
|
||||||
return mount.Mount{}, fmt.Errorf("invalid volume: %s", volumeSpec)
|
Propagation: mount.Propagation(volume.Bind.Propagation),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Binds volumes
|
||||||
switch len(parts) {
|
if volume.Type == "bind" {
|
||||||
case 3:
|
return result, nil
|
||||||
source = parts[0]
|
|
||||||
target = parts[1]
|
|
||||||
mode = strings.Split(parts[2], ",")
|
|
||||||
case 2:
|
|
||||||
source = parts[0]
|
|
||||||
target = parts[1]
|
|
||||||
case 1:
|
|
||||||
target = parts[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == "" {
|
stackVolume, exists := stackVolumes[volume.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]
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return mount.Mount{}, fmt.Errorf("undefined volume: %s", source)
|
return result, errors.Errorf("undefined volume: %s", volume.Source)
|
||||||
}
|
}
|
||||||
|
|
||||||
var volumeOptions *mount.VolumeOptions
|
result.Source = namespace.Scope(volume.Source)
|
||||||
if stackVolume.External.Name != "" {
|
result.VolumeOptions = &mount.VolumeOptions{}
|
||||||
volumeOptions = &mount.VolumeOptions{
|
|
||||||
NoCopy: isNoCopy(mode),
|
|
||||||
}
|
|
||||||
source = stackVolume.External.Name
|
|
||||||
} else {
|
|
||||||
volumeOptions = &mount.VolumeOptions{
|
|
||||||
Labels: AddStackLabel(namespace, stackVolume.Labels),
|
|
||||||
NoCopy: isNoCopy(mode),
|
|
||||||
}
|
|
||||||
|
|
||||||
if stackVolume.Driver != "" {
|
if volume.Volume != nil {
|
||||||
volumeOptions.DriverConfig = &mount.Driver{
|
result.VolumeOptions.NoCopy = volume.Volume.NoCopy
|
||||||
Name: stackVolume.Driver,
|
|
||||||
Options: stackVolume.DriverOpts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
source = namespace.Scope(source)
|
|
||||||
}
|
}
|
||||||
return mount.Mount{
|
|
||||||
Type: mount.TypeVolume,
|
|
||||||
Source: source,
|
|
||||||
Target: target,
|
|
||||||
ReadOnly: isReadOnly(mode),
|
|
||||||
VolumeOptions: volumeOptions,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func modeHas(mode []string, field string) bool {
|
// External named volumes
|
||||||
for _, item := range mode {
|
if stackVolume.External.External {
|
||||||
if item == field {
|
result.Source = stackVolume.External.Name
|
||||||
return true
|
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 {
|
// Named volumes
|
||||||
return modeHas(mode, "ro")
|
return result, nil
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,51 +8,48 @@ import (
|
||||||
"github.com/docker/docker/pkg/testutil/assert"
|
"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) {
|
func TestConvertVolumeToMountAnonymousVolume(t *testing.T) {
|
||||||
stackVolumes := volumes{}
|
config := composetypes.ServiceVolumeConfig{
|
||||||
namespace := NewNamespace("foo")
|
Type: "volume",
|
||||||
|
Target: "/foo/bar",
|
||||||
|
}
|
||||||
expected := mount.Mount{
|
expected := mount.Mount{
|
||||||
Type: mount.TypeVolume,
|
Type: mount.TypeVolume,
|
||||||
Target: "/foo/bar",
|
Target: "/foo/bar",
|
||||||
}
|
}
|
||||||
mount, err := convertVolumeToMount("/foo/bar", stackVolumes, namespace)
|
mount, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, mount, expected)
|
assert.DeepEqual(t, mount, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertVolumeToMountInvalidFormat(t *testing.T) {
|
func TestConvertVolumeToMountConflictingOptionsBind(t *testing.T) {
|
||||||
namespace := NewNamespace("foo")
|
namespace := NewNamespace("foo")
|
||||||
invalids := []string{"::", "::cc", ":bb:", "aa::", "aa::cc", "aa:bb:", " : : ", " : :cc", " :bb: ", "aa: : ", "aa: :cc", "aa:bb: "}
|
|
||||||
for _, vol := range invalids {
|
config := composetypes.ServiceVolumeConfig{
|
||||||
_, err := convertVolumeToMount(vol, volumes{}, namespace)
|
Type: "volume",
|
||||||
assert.Error(t, err, "invalid volume: "+vol)
|
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) {
|
func TestConvertVolumeToMountNamedVolume(t *testing.T) {
|
||||||
|
@ -84,9 +81,19 @@ func TestConvertVolumeToMountNamedVolume(t *testing.T) {
|
||||||
"opt": "value",
|
"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.NilError(t, err)
|
||||||
assert.DeepEqual(t, mount, expected)
|
assert.DeepEqual(t, mount, expected)
|
||||||
}
|
}
|
||||||
|
@ -109,7 +116,12 @@ func TestConvertVolumeToMountNamedVolumeExternal(t *testing.T) {
|
||||||
NoCopy: false,
|
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.NilError(t, err)
|
||||||
assert.DeepEqual(t, mount, expected)
|
assert.DeepEqual(t, mount, expected)
|
||||||
}
|
}
|
||||||
|
@ -132,7 +144,15 @@ func TestConvertVolumeToMountNamedVolumeExternalNoCopy(t *testing.T) {
|
||||||
NoCopy: true,
|
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.NilError(t, err)
|
||||||
assert.DeepEqual(t, mount, expected)
|
assert.DeepEqual(t, mount, expected)
|
||||||
}
|
}
|
||||||
|
@ -147,13 +167,26 @@ func TestConvertVolumeToMountBind(t *testing.T) {
|
||||||
ReadOnly: true,
|
ReadOnly: true,
|
||||||
BindOptions: &mount.BindOptions{Propagation: mount.PropagationShared},
|
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.NilError(t, err)
|
||||||
assert.DeepEqual(t, mount, expected)
|
assert.DeepEqual(t, mount, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertVolumeToMountVolumeDoesNotExist(t *testing.T) {
|
func TestConvertVolumeToMountVolumeDoesNotExist(t *testing.T) {
|
||||||
namespace := NewNamespace("foo")
|
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")
|
assert.Error(t, err, "undefined volume: unknown")
|
||||||
}
|
}
|
||||||
|
|
|
@ -251,6 +251,8 @@ func transformHook(
|
||||||
return transformMappingOrList(data, "="), nil
|
return transformMappingOrList(data, "="), nil
|
||||||
case reflect.TypeOf(types.MappingWithColon{}):
|
case reflect.TypeOf(types.MappingWithColon{}):
|
||||||
return transformMappingOrList(data, ":"), nil
|
return transformMappingOrList(data, ":"), nil
|
||||||
|
case reflect.TypeOf(types.ServiceVolumeConfig{}):
|
||||||
|
return transformServiceVolumeConfig(data)
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
@ -333,10 +335,7 @@ func LoadService(name string, serviceDict types.Dict, workingDir string) (*types
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := resolveVolumePaths(serviceConfig.Volumes, workingDir); err != nil {
|
resolveVolumePaths(serviceConfig.Volumes, workingDir)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return serviceConfig, nil
|
return serviceConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -369,22 +368,15 @@ func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string) e
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveVolumePaths(volumes []string, workingDir string) error {
|
func resolveVolumePaths(volumes []types.ServiceVolumeConfig, workingDir string) {
|
||||||
for i, mapping := range volumes {
|
for i, volume := range volumes {
|
||||||
parts := strings.SplitN(mapping, ":", 2)
|
if volume.Type != "bind" {
|
||||||
if len(parts) == 1 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(parts[0], ".") {
|
volume.Source = absPath(workingDir, expandUser(volume.Source))
|
||||||
parts[0] = absPath(workingDir, parts[0])
|
volumes[i] = volume
|
||||||
}
|
|
||||||
parts[0] = expandUser(parts[0])
|
|
||||||
|
|
||||||
volumes[i] = strings.Join(parts, ":")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make this more robust
|
// 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) {
|
func transformServiceNetworkMap(value interface{}) (interface{}, error) {
|
||||||
if list, ok := value.([]interface{}); ok {
|
if list, ok := value.([]interface{}); ok {
|
||||||
mapValue := map[interface{}]interface{}{}
|
mapValue := map[interface{}]interface{}{}
|
||||||
|
|
|
@ -881,13 +881,13 @@ func TestFullExample(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
User: "someone",
|
User: "someone",
|
||||||
Volumes: []string{
|
Volumes: []types.ServiceVolumeConfig{
|
||||||
"/var/lib/mysql",
|
{Target: "/var/lib/mysql", Type: "volume"},
|
||||||
"/opt/data:/var/lib/mysql",
|
{Source: "/opt/data", Target: "/var/lib/mysql", Type: "bind"},
|
||||||
fmt.Sprintf("%s:/code", workingDir),
|
{Source: workingDir, Target: "/code", Type: "bind"},
|
||||||
fmt.Sprintf("%s/static:/var/www/html", workingDir),
|
{Source: workingDir + "/static", Target: "/var/www/html", Type: "bind"},
|
||||||
fmt.Sprintf("%s/configs:/etc/configs/:ro", homeDir),
|
{Source: homeDir + "/configs", Target: "/etc/configs/", Type: "bind", ReadOnly: true},
|
||||||
"datavolume:/var/lib/mysql",
|
{Source: "datavolume", Target: "/var/lib/mysql", Type: "volume"},
|
||||||
},
|
},
|
||||||
WorkingDir: "/code",
|
WorkingDir: "/code",
|
||||||
}
|
}
|
||||||
|
@ -1085,3 +1085,31 @@ services:
|
||||||
assert.Equal(t, 1, len(config.Services))
|
assert.Equal(t, 1, len(config.Services))
|
||||||
assert.Equal(t, expected, config.Services[0].Ports)
|
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"},
|
"user": {"type": "string"},
|
||||||
"userns_mode": {"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"}
|
"working_dir": {"type": "string"}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
|
@ -78,18 +78,27 @@ func Validate(config map[string]interface{}, version string) error {
|
||||||
|
|
||||||
func toError(result *gojsonschema.Result) error {
|
func toError(result *gojsonschema.Result) error {
|
||||||
err := getMostSpecificError(result.Errors())
|
err := getMostSpecificError(result.Errors())
|
||||||
description := getDescription(err)
|
return err
|
||||||
return fmt.Errorf("%s %s", err.Field(), description)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDescription(err gojsonschema.ResultError) string {
|
const (
|
||||||
if err.Type() == "invalid_type" {
|
jsonschemaOneOf = "number_one_of"
|
||||||
if expectedType, ok := err.Details()["expected"].(string); ok {
|
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))
|
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.parent.Description()
|
||||||
return err.Description()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanReadableType(definition string) string {
|
func humanReadableType(definition string) string {
|
||||||
|
@ -113,23 +122,45 @@ func humanReadableType(definition string) string {
|
||||||
return definition
|
return definition
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMostSpecificError(errors []gojsonschema.ResultError) gojsonschema.ResultError {
|
type validationError struct {
|
||||||
var mostSpecificError gojsonschema.ResultError
|
parent gojsonschema.ResultError
|
||||||
|
child gojsonschema.ResultError
|
||||||
|
}
|
||||||
|
|
||||||
for _, err := range errors {
|
func (err validationError) Error() string {
|
||||||
if mostSpecificError == nil {
|
description := getDescription(err)
|
||||||
mostSpecificError = err
|
return fmt.Sprintf("%s %s", err.parent.Field(), description)
|
||||||
} else if specificity(err) > specificity(mostSpecificError) {
|
}
|
||||||
mostSpecificError = err
|
|
||||||
} else if specificity(err) == specificity(mostSpecificError) {
|
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
|
// Invalid type errors win in a tie-breaker for most specific field name
|
||||||
if err.Type() == "invalid_type" && mostSpecificError.Type() != "invalid_type" {
|
if err.Type() == "invalid_type" && errors[mostSpecificError].Type() != "invalid_type" {
|
||||||
mostSpecificError = err
|
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 {
|
func specificity(err gojsonschema.ResultError) int {
|
||||||
|
|
|
@ -119,7 +119,7 @@ type ServiceConfig struct {
|
||||||
Tty bool `mapstructure:"tty"`
|
Tty bool `mapstructure:"tty"`
|
||||||
Ulimits map[string]*UlimitsConfig
|
Ulimits map[string]*UlimitsConfig
|
||||||
User string
|
User string
|
||||||
Volumes []string
|
Volumes []ServiceVolumeConfig
|
||||||
WorkingDir string `mapstructure:"working_dir"`
|
WorkingDir string `mapstructure:"working_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -223,6 +223,26 @@ type ServicePortConfig struct {
|
||||||
Protocol string
|
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
|
// ServiceSecretConfig is the secret configuration for a service
|
||||||
type ServiceSecretConfig struct {
|
type ServiceSecretConfig struct {
|
||||||
Source string
|
Source string
|
||||||
|
|
Loading…
Reference in New Issue