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:
Justin Cormack 2017-03-14 17:53:28 +00:00 committed by GitHub
commit b8c49df008
10 changed files with 534 additions and 178 deletions

View File

@ -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,
// TODO: split Windows path mappings properly ) (mount.Mount, error) {
parts := strings.SplitN(volumeSpec, ":", 3) result := mount.Mount{
Type: mount.Type(volume.Type),
for _, part := range parts { Source: volume.Source,
if strings.TrimSpace(part) == "" { Target: volume.Target,
return mount.Mount{}, fmt.Errorf("invalid volume: %s", volumeSpec) ReadOnly: volume.ReadOnly,
}
} }
switch len(parts) { // Anonymous volumes
case 3: if volume.Source == "" {
source = parts[0] return result, nil
target = parts[1] }
mode = strings.Split(parts[2], ",") if volume.Type == "volume" && volume.Bind != nil {
case 2: return result, errors.New("bind options are incompatible with type volume")
source = parts[0] }
target = parts[1] if volume.Type == "bind" && volume.Volume != nil {
case 1: return result, errors.New("volume options are incompatible with type bind")
target = parts[0]
} }
if source == "" { if volume.Bind != nil {
// Anonymous volume result.BindOptions = &mount.BindOptions{
return mount.Mount{ Propagation: mount.Propagation(volume.Bind.Propagation),
Type: mount.TypeVolume, }
Target: target, }
}, nil // Binds volumes
if volume.Type == "bind" {
return result, nil
} }
// TODO: catch Windows paths here stackVolume, exists := stackVolumes[volume.Source]
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), if volume.Volume != nil {
} result.VolumeOptions.NoCopy = volume.Volume.NoCopy
source = stackVolume.External.Name
} else {
volumeOptions = &mount.VolumeOptions{
Labels: AddStackLabel(namespace, stackVolume.Labels),
NoCopy: isNoCopy(mode),
} }
if stackVolume.Driver != "" { // External named volumes
volumeOptions.DriverConfig = &mount.Driver{ 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, Name: stackVolume.Driver,
Options: stackVolume.DriverOpts, 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 { // Named volumes
for _, item := range mode { return result, nil
if item == field {
return true
}
}
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
} }

View File

@ -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")
} }

View File

@ -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{}{}

View File

@ -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])
}

119
compose/loader/volume.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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.Description() }
return err.parent.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 {

View File

@ -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