diff --git a/api/types/mount/mount.go b/api/types/mount/mount.go index 8ac89402f9..31f2365b8e 100644 --- a/api/types/mount/mount.go +++ b/api/types/mount/mount.go @@ -50,6 +50,16 @@ const ( PropagationSlave Propagation = "slave" ) +// Propagations is the list of all valid mount propagations +var Propagations = []Propagation{ + PropagationRPrivate, + PropagationPrivate, + PropagationRShared, + PropagationShared, + PropagationRSlave, + PropagationSlave, +} + // BindOptions defines options specific to mounts of type "bind". type BindOptions struct { Propagation Propagation `json:",omitempty"` diff --git a/cli/command/stack/common.go b/cli/command/stack/common.go index 4ae8184933..050528de4e 100644 --- a/cli/command/stack/common.go +++ b/cli/command/stack/common.go @@ -7,11 +7,12 @@ import ( "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/client" + "github.com/docker/docker/pkg/composetransform" ) func getStackFilter(namespace string) filters.Args { filter := filters.NewArgs() - filter.Add("label", labelNamespace+"="+namespace) + filter.Add("label", composetransform.LabelNamespace+"="+namespace) return filter } diff --git a/cli/command/stack/deploy.go b/cli/command/stack/deploy.go index 0fda934220..b4753dd67e 100644 --- a/cli/command/stack/deploy.go +++ b/cli/command/stack/deploy.go @@ -13,13 +13,12 @@ import ( composetypes "github.com/aanand/compose-file/types" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/mount" - networktypes "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" dockerclient "github.com/docker/docker/client" "github.com/docker/docker/opts" + "github.com/docker/docker/pkg/composetransform" runconfigopts "github.com/docker/docker/runconfig/opts" "github.com/docker/go-connections/nat" "github.com/spf13/cobra" @@ -120,10 +119,10 @@ func deployCompose(ctx context.Context, dockerCli *command.DockerCli, opts deplo return err } - namespace := namespace{name: opts.namespace} + namespace := composetransform.NewNamespace(opts.namespace) serviceNetworks := getServicesDeclaredNetworks(config.Services) - networks, externalNetworks := convertNetworks(namespace, config.Networks, serviceNetworks) + networks, externalNetworks := composetransform.ConvertNetworks(namespace, config.Networks, serviceNetworks) if err := validateExternalNetworks(ctx, dockerCli, externalNetworks); err != nil { return err } @@ -217,12 +216,12 @@ func validateExternalNetworks( func createNetworks( ctx context.Context, dockerCli *command.DockerCli, - namespace namespace, + namespace composetransform.Namespace, networks map[string]types.NetworkCreate, ) error { client := dockerCli.Client() - existingNetworks, err := getStackNetworks(ctx, client, namespace.name) + existingNetworks, err := getStackNetworks(ctx, client, namespace.Name()) if err != nil { return err } @@ -233,7 +232,7 @@ func createNetworks( } for internalName, createOpts := range networks { - name := namespace.scope(internalName) + name := namespace.Scope(internalName) if _, exists := existingNetworkMap[name]; exists { continue } @@ -254,7 +253,7 @@ func createNetworks( func convertServiceNetworks( networks map[string]*composetypes.ServiceNetworkConfig, networkConfigs map[string]composetypes.NetworkConfig, - namespace namespace, + namespace composetransform.Namespace, name string, ) ([]swarm.NetworkAttachmentConfig, error) { if len(networks) == 0 { @@ -288,128 +287,6 @@ func convertServiceNetworks( return nets, nil } -func convertVolumes( - serviceVolumes []string, - stackVolumes map[string]composetypes.VolumeConfig, - namespace namespace, -) ([]mount.Mount, error) { - var mounts []mount.Mount - - for _, volumeSpec := range serviceVolumes { - mount, err := convertVolumeToMount(volumeSpec, stackVolumes, namespace) - if err != nil { - return nil, err - } - mounts = append(mounts, mount) - } - return mounts, nil -} - -func convertVolumeToMount( - volumeSpec string, - stackVolumes map[string]composetypes.VolumeConfig, - 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) - } - } - - 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] - } - - 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] - if !exists { - return mount.Mount{}, fmt.Errorf("undefined volume: %s", source) - } - - var volumeOptions *mount.VolumeOptions - if stackVolume.External.Name != "" { - source = stackVolume.External.Name - } else { - volumeOptions = &mount.VolumeOptions{ - Labels: getStackLabels(namespace.name, stackVolume.Labels), - NoCopy: isNoCopy(mode), - } - - if stackVolume.Driver != "" { - volumeOptions.DriverConfig = &mount.Driver{ - 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 { - for _, item := range mode { - 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 { - if strings.Contains(item, "private") || strings.Contains(item, "shared") || strings.Contains(item, "slave") { - return &mount.BindOptions{Propagation: mount.Propagation(item)} - } - } - return nil -} - func deployServices( ctx context.Context, dockerCli *command.DockerCli, @@ -519,7 +396,7 @@ func convertService( return swarm.ServiceSpec{}, err } - mounts, err := convertVolumes(service.Volumes, volumes, namespace) + mounts, err := composetransform.ConvertVolumes(service.Volumes, volumes, namespace) if err != nil { // TODO: better error message (include service name) return swarm.ServiceSpec{}, err diff --git a/pkg/composetransform/compose.go b/pkg/composetransform/compose.go index ba7852d617..dc83915a0f 100644 --- a/pkg/composetransform/compose.go +++ b/pkg/composetransform/compose.go @@ -7,7 +7,8 @@ import ( ) const ( - labelNamespace = "com.docker.stack.namespace" + // LabelNamespace is the label used to track stack resources + LabelNamespace = "com.docker.stack.namespace" ) // Namespace mangles names by prepending the name @@ -20,12 +21,22 @@ func (n Namespace) Scope(name string) string { return n.name + "_" + name } +// Name returns the name of the namespace +func (n Namespace) Name() string { + return n.name +} + +// NewNamespace returns a new Namespace for scoping of names +func NewNamespace(name string) Namespace { + return Namespace{name: name} +} + // AddStackLabel returns labels with the namespace label added func AddStackLabel(namespace Namespace, labels map[string]string) map[string]string { if labels == nil { labels = make(map[string]string) } - labels[labelNamespace] = namespace.name + labels[LabelNamespace] = namespace.name return labels } diff --git a/pkg/composetransform/compose_test.go b/pkg/composetransform/compose_test.go index 91a089885c..b09750e891 100644 --- a/pkg/composetransform/compose_test.go +++ b/pkg/composetransform/compose_test.go @@ -21,7 +21,7 @@ func TestAddStackLabel(t *testing.T) { actual := AddStackLabel(Namespace{name: "foo"}, labels) expected := map[string]string{ "something": "labeled", - labelNamespace: "foo", + LabelNamespace: "foo", } assert.DeepEqual(t, actual, expected) } @@ -56,7 +56,7 @@ func TestConvertNetworks(t *testing.T) { expected := map[string]types.NetworkCreate{ "default": { Labels: map[string]string{ - labelNamespace: "foo", + LabelNamespace: "foo", }, }, "normal": { @@ -73,7 +73,7 @@ func TestConvertNetworks(t *testing.T) { "opt": "value", }, Labels: map[string]string{ - labelNamespace: "foo", + LabelNamespace: "foo", "something": "labeled", }, }, diff --git a/pkg/composetransform/volume.go b/pkg/composetransform/volume.go new file mode 100644 index 0000000000..7dce0504b4 --- /dev/null +++ b/pkg/composetransform/volume.go @@ -0,0 +1,120 @@ +package composetransform + +import ( + "fmt" + "strings" + + composetypes "github.com/aanand/compose-file/types" + "github.com/docker/docker/api/types/mount" +) + +type volumes map[string]composetypes.VolumeConfig + +// ConvertVolumes from compose-file types to engine api types +func ConvertVolumes(serviceVolumes []string, stackVolumes volumes, namespace Namespace) ([]mount.Mount, error) { + var mounts []mount.Mount + + for _, volumeSpec := range serviceVolumes { + mount, err := convertVolumeToMount(volumeSpec, stackVolumes, namespace) + if err != nil { + return nil, err + } + mounts = append(mounts, mount) + } + 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) + } + } + + 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] + } + + // 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 { + return mount.Mount{}, fmt.Errorf("undefined volume: %s", source) + } + + var volumeOptions *mount.VolumeOptions + if stackVolume.External.Name != "" { + source = stackVolume.External.Name + } else { + volumeOptions = &mount.VolumeOptions{ + Labels: AddStackLabel(namespace, stackVolume.Labels), + NoCopy: isNoCopy(mode), + } + + if stackVolume.Driver != "" { + volumeOptions.DriverConfig = &mount.Driver{ + 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 { + for _, item := range mode { + 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 +} diff --git a/pkg/composetransform/volume_test.go b/pkg/composetransform/volume_test.go new file mode 100644 index 0000000000..fd5a2a84b3 --- /dev/null +++ b/pkg/composetransform/volume_test.go @@ -0,0 +1,112 @@ +package composetransform + +import ( + "testing" + + composetypes "github.com/aanand/compose-file/types" + "github.com/docker/docker/api/types/mount" + "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, nil) +} + +func TestConvertVolumeToMountNamedVolume(t *testing.T) { + stackVolumes := volumes{ + "normal": composetypes.VolumeConfig{ + Driver: "glusterfs", + DriverOpts: map[string]string{ + "opt": "value", + }, + Labels: map[string]string{ + "something": "labeled", + }, + }, + } + namespace := NewNamespace("foo") + expected := mount.Mount{ + Type: mount.TypeVolume, + Source: "foo_normal", + Target: "/foo", + ReadOnly: true, + VolumeOptions: &mount.VolumeOptions{ + Labels: map[string]string{ + LabelNamespace: "foo", + "something": "labeled", + }, + DriverConfig: &mount.Driver{ + Name: "glusterfs", + Options: map[string]string{ + "opt": "value", + }, + }, + }, + } + mount, err := convertVolumeToMount("normal:/foo:ro", stackVolumes, namespace) + assert.NilError(t, err) + assert.DeepEqual(t, mount, expected) +} + +func TestConvertVolumeToMountNamedVolumeExternal(t *testing.T) { + stackVolumes := volumes{ + "outside": composetypes.VolumeConfig{ + External: composetypes.External{ + External: true, + Name: "special", + }, + }, + } + namespace := NewNamespace("foo") + expected := mount.Mount{ + Type: mount.TypeVolume, + Source: "special", + Target: "/foo", + } + mount, err := convertVolumeToMount("outside:/foo", stackVolumes, namespace) + assert.NilError(t, err) + assert.DeepEqual(t, mount, expected) +} + +func TestConvertVolumeToMountBind(t *testing.T) { + stackVolumes := volumes{} + namespace := NewNamespace("foo") + expected := mount.Mount{ + Type: mount.TypeBind, + Source: "/bar", + Target: "/foo", + ReadOnly: true, + BindOptions: &mount.BindOptions{Propagation: mount.PropagationShared}, + } + mount, err := convertVolumeToMount("/bar:/foo:ro,shared", 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) + assert.Error(t, err, "undefined volume: unknown") +}