Merge pull request #30515 from vieux/1.13.1-rc1-changelog

bump to 1.13.1-rc1
diff --git a/api/swagger.yaml b/api/swagger.yaml
index ad97bb2..2f5b975 100644
--- a/api/swagger.yaml
+++ b/api/swagger.yaml
@@ -7678,3 +7678,36 @@
           type: "string"
           description: "ID of the secret"
       tags: ["Secret"]
+  /secrets/{id}/update:
+    post:
+      summary: "Update a Secret"
+      operationId: "SecretUpdate"
+      responses:
+        200:
+          description: "no error"
+        404:
+          description: "no such secret"
+          schema:
+            $ref: "#/definitions/ErrorResponse"
+        500:
+          description: "server error"
+          schema:
+            $ref: "#/definitions/ErrorResponse"
+      parameters:
+        - name: "id"
+          in: "path"
+          description: "The ID of the secret"
+          type: "string"
+          required: true
+        - name: "body"
+          in: "body"
+          schema:
+            $ref: "#/definitions/SecretSpec"
+          description: "The spec of the secret to update. Currently, only the Labels field can be updated. All other fields must remain unchanged from the [SecretInspect endpoint](#operation/SecretInspect) response values."
+        - name: "version"
+          in: "query"
+          description: "The version number of the secret object being updated. This is required to avoid conflicting writes."
+          type: "integer"
+          format: "int64"
+          required: true
+      tags: ["Secret"]
diff --git a/cli/command/plugin/disable.go b/cli/command/plugin/disable.go
index c3d36e2..07b0ec2 100644
--- a/cli/command/plugin/disable.go
+++ b/cli/command/plugin/disable.go
@@ -14,7 +14,7 @@
 	var force bool
 
 	cmd := &cobra.Command{
-		Use:   "disable PLUGIN",
+		Use:   "disable [OPTIONS] PLUGIN",
 		Short: "Disable a plugin",
 		Args:  cli.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/cli/command/plugin/push.go b/cli/command/plugin/push.go
index b076630..9abb38e 100644
--- a/cli/command/plugin/push.go
+++ b/cli/command/plugin/push.go
@@ -16,7 +16,7 @@
 
 func newPushCommand(dockerCli *command.DockerCli) *cobra.Command {
 	cmd := &cobra.Command{
-		Use:   "push PLUGIN[:TAG]",
+		Use:   "push [OPTIONS] PLUGIN[:TAG]",
 		Short: "Push a plugin to a registry",
 		Args:  cli.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/cli/command/secret/utils.go b/cli/command/secret/utils.go
index 4249389..11d31ff 100644
--- a/cli/command/secret/utils.go
+++ b/cli/command/secret/utils.go
@@ -11,7 +11,8 @@
 	"golang.org/x/net/context"
 )
 
-func getSecretsByNameOrIDPrefixes(ctx context.Context, client client.APIClient, terms []string) ([]swarm.Secret, error) {
+// GetSecretsByNameOrIDPrefixes returns secrets given a list of ids or names
+func GetSecretsByNameOrIDPrefixes(ctx context.Context, client client.APIClient, terms []string) ([]swarm.Secret, error) {
 	args := filters.NewArgs()
 	for _, n := range terms {
 		args.Add("names", n)
@@ -24,7 +25,7 @@
 }
 
 func getCliRequestedSecretIDs(ctx context.Context, client client.APIClient, terms []string) ([]string, error) {
-	secrets, err := getSecretsByNameOrIDPrefixes(ctx, client, terms)
+	secrets, err := GetSecretsByNameOrIDPrefixes(ctx, client, terms)
 	if err != nil {
 		return nil, err
 	}
diff --git a/cli/command/service/create.go b/cli/command/service/create.go
index ca2bb08..1355c19 100644
--- a/cli/command/service/create.go
+++ b/cli/command/service/create.go
@@ -62,7 +62,7 @@
 	specifiedSecrets := opts.secrets.Value()
 	if len(specifiedSecrets) > 0 {
 		// parse and validate secrets
-		secrets, err := parseSecrets(apiClient, specifiedSecrets)
+		secrets, err := ParseSecrets(apiClient, specifiedSecrets)
 		if err != nil {
 			return err
 		}
diff --git a/cli/command/service/parse.go b/cli/command/service/parse.go
index ff3249e..ce9b454 100644
--- a/cli/command/service/parse.go
+++ b/cli/command/service/parse.go
@@ -10,9 +10,9 @@
 	"golang.org/x/net/context"
 )
 
-// parseSecrets retrieves the secrets from the requested names and converts
+// ParseSecrets retrieves the secrets from the requested names and converts
 // them to secret references to use with the spec
-func parseSecrets(client client.APIClient, requestedSecrets []*types.SecretRequestOption) ([]*swarmtypes.SecretReference, error) {
+func ParseSecrets(client client.SecretAPIClient, requestedSecrets []*types.SecretRequestOption) ([]*swarmtypes.SecretReference, error) {
 	secretRefs := make(map[string]*swarmtypes.SecretReference)
 	ctx := context.Background()
 
diff --git a/cli/command/service/update.go b/cli/command/service/update.go
index 514b1bd..d56de10 100644
--- a/cli/command/service/update.go
+++ b/cli/command/service/update.go
@@ -431,11 +431,11 @@
 	*field = removeItems(*field, toRemove, envKey)
 }
 
-func getUpdatedSecrets(apiClient client.APIClient, flags *pflag.FlagSet, secrets []*swarm.SecretReference) ([]*swarm.SecretReference, error) {
+func getUpdatedSecrets(apiClient client.SecretAPIClient, flags *pflag.FlagSet, secrets []*swarm.SecretReference) ([]*swarm.SecretReference, error) {
 	if flags.Changed(flagSecretAdd) {
 		values := flags.Lookup(flagSecretAdd).Value.(*opts.SecretOpt).Value()
 
-		addSecrets, err := parseSecrets(apiClient, values)
+		addSecrets, err := ParseSecrets(apiClient, values)
 		if err != nil {
 			return nil, err
 		}
diff --git a/cli/command/stack/common.go b/cli/command/stack/common.go
index 5c4996d..72719f9 100644
--- a/cli/command/stack/common.go
+++ b/cli/command/stack/common.go
@@ -48,3 +48,13 @@
 		ctx,
 		types.NetworkListOptions{Filters: getStackFilter(namespace)})
 }
+
+func getStackSecrets(
+	ctx context.Context,
+	apiclient client.APIClient,
+	namespace string,
+) ([]swarm.Secret, error) {
+	return apiclient.SecretList(
+		ctx,
+		types.SecretListOptions{Filters: getStackFilter(namespace)})
+}
diff --git a/cli/command/stack/deploy.go b/cli/command/stack/deploy.go
index ec50ff1..980876a 100644
--- a/cli/command/stack/deploy.go
+++ b/cli/command/stack/deploy.go
@@ -1,7 +1,6 @@
 package stack
 
 import (
-	"errors"
 	"fmt"
 	"io/ioutil"
 	"os"
@@ -12,10 +11,12 @@
 	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/cli"
 	"github.com/docker/docker/cli/command"
+	secretcli "github.com/docker/docker/cli/command/secret"
 	"github.com/docker/docker/cli/compose/convert"
 	"github.com/docker/docker/cli/compose/loader"
 	composetypes "github.com/docker/docker/cli/compose/types"
 	dockerclient "github.com/docker/docker/client"
+	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
 	"golang.org/x/net/context"
 )
@@ -124,7 +125,16 @@
 	if err := createNetworks(ctx, dockerCli, namespace, networks); err != nil {
 		return err
 	}
-	services, err := convert.Services(namespace, config)
+
+	secrets, err := convert.Secrets(namespace, config.Secrets)
+	if err != nil {
+		return err
+	}
+	if err := createSecrets(ctx, dockerCli, namespace, secrets); err != nil {
+		return err
+	}
+
+	services, err := convert.Services(namespace, config, dockerCli.Client())
 	if err != nil {
 		return err
 	}
@@ -208,6 +218,37 @@
 	return nil
 }
 
+func createSecrets(
+	ctx context.Context,
+	dockerCli *command.DockerCli,
+	namespace convert.Namespace,
+	secrets []swarm.SecretSpec,
+) error {
+	client := dockerCli.Client()
+
+	for _, secretSpec := range secrets {
+		// TODO: fix this after https://github.com/docker/docker/pull/29218
+		secrets, err := secretcli.GetSecretsByNameOrIDPrefixes(ctx, client, []string{secretSpec.Name})
+		switch {
+		case err != nil:
+			return err
+		case len(secrets) > 1:
+			return errors.Errorf("ambiguous secret name: %s", secretSpec.Name)
+		case len(secrets) == 0:
+			fmt.Fprintf(dockerCli.Out(), "Creating secret %s\n", secretSpec.Name)
+			_, err = client.SecretCreate(ctx, secretSpec)
+		default:
+			secret := secrets[0]
+			// Update secret to ensure that the local data hasn't changed
+			err = client.SecretUpdate(ctx, secret.ID, secret.Meta.Version, secretSpec)
+		}
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func createNetworks(
 	ctx context.Context,
 	dockerCli *command.DockerCli,
diff --git a/cli/command/stack/remove.go b/cli/command/stack/remove.go
index 734ff92..966c1aa 100644
--- a/cli/command/stack/remove.go
+++ b/cli/command/stack/remove.go
@@ -3,11 +3,12 @@
 import (
 	"fmt"
 
-	"golang.org/x/net/context"
-
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/cli"
 	"github.com/docker/docker/cli/command"
 	"github.com/spf13/cobra"
+	"golang.org/x/net/context"
 )
 
 type removeOptions struct {
@@ -33,41 +34,79 @@
 func runRemove(dockerCli *command.DockerCli, opts removeOptions) error {
 	namespace := opts.namespace
 	client := dockerCli.Client()
-	stderr := dockerCli.Err()
 	ctx := context.Background()
-	hasError := false
 
 	services, err := getServices(ctx, client, namespace)
 	if err != nil {
 		return err
 	}
-	for _, service := range services {
-		fmt.Fprintf(stderr, "Removing service %s\n", service.Spec.Name)
-		if err := client.ServiceRemove(ctx, service.ID); err != nil {
-			hasError = true
-			fmt.Fprintf(stderr, "Failed to remove service %s: %s", service.ID, err)
-		}
-	}
 
 	networks, err := getStackNetworks(ctx, client, namespace)
 	if err != nil {
 		return err
 	}
-	for _, network := range networks {
-		fmt.Fprintf(stderr, "Removing network %s\n", network.Name)
-		if err := client.NetworkRemove(ctx, network.ID); err != nil {
-			hasError = true
-			fmt.Fprintf(stderr, "Failed to remove network %s: %s", network.ID, err)
-		}
+
+	secrets, err := getStackSecrets(ctx, client, namespace)
+	if err != nil {
+		return err
 	}
 
-	if len(services) == 0 && len(networks) == 0 {
+	if len(services)+len(networks)+len(secrets) == 0 {
 		fmt.Fprintf(dockerCli.Out(), "Nothing found in stack: %s\n", namespace)
 		return nil
 	}
 
+	hasError := removeServices(ctx, dockerCli, services)
+	hasError = removeSecrets(ctx, dockerCli, secrets) || hasError
+	hasError = removeNetworks(ctx, dockerCli, networks) || hasError
+
 	if hasError {
 		return fmt.Errorf("Failed to remove some resources")
 	}
 	return nil
 }
+
+func removeServices(
+	ctx context.Context,
+	dockerCli *command.DockerCli,
+	services []swarm.Service,
+) bool {
+	var err error
+	for _, service := range services {
+		fmt.Fprintf(dockerCli.Err(), "Removing service %s\n", service.Spec.Name)
+		if err = dockerCli.Client().ServiceRemove(ctx, service.ID); err != nil {
+			fmt.Fprintf(dockerCli.Err(), "Failed to remove service %s: %s", service.ID, err)
+		}
+	}
+	return err != nil
+}
+
+func removeNetworks(
+	ctx context.Context,
+	dockerCli *command.DockerCli,
+	networks []types.NetworkResource,
+) bool {
+	var err error
+	for _, network := range networks {
+		fmt.Fprintf(dockerCli.Err(), "Removing network %s\n", network.Name)
+		if err = dockerCli.Client().NetworkRemove(ctx, network.ID); err != nil {
+			fmt.Fprintf(dockerCli.Err(), "Failed to remove network %s: %s", network.ID, err)
+		}
+	}
+	return err != nil
+}
+
+func removeSecrets(
+	ctx context.Context,
+	dockerCli *command.DockerCli,
+	secrets []swarm.Secret,
+) bool {
+	var err error
+	for _, secret := range secrets {
+		fmt.Fprintf(dockerCli.Err(), "Removing secret %s\n", secret.Spec.Name)
+		if err = dockerCli.Client().SecretRemove(ctx, secret.ID); err != nil {
+			fmt.Fprintf(dockerCli.Err(), "Failed to remove secret %s: %s", secret.ID, err)
+		}
+	}
+	return err != nil
+}
diff --git a/cli/compose/convert/compose.go b/cli/compose/convert/compose.go
index 7091a09..8122326 100644
--- a/cli/compose/convert/compose.go
+++ b/cli/compose/convert/compose.go
@@ -1,8 +1,11 @@
 package convert
 
 import (
+	"io/ioutil"
+
 	"github.com/docker/docker/api/types"
 	networktypes "github.com/docker/docker/api/types/network"
+	"github.com/docker/docker/api/types/swarm"
 	composetypes "github.com/docker/docker/cli/compose/types"
 )
 
@@ -87,3 +90,27 @@
 
 	return result, externalNetworks
 }
+
+// Secrets converts secrets from the Compose type to the engine API type
+func Secrets(namespace Namespace, secrets map[string]composetypes.SecretConfig) ([]swarm.SecretSpec, error) {
+	result := []swarm.SecretSpec{}
+	for name, secret := range secrets {
+		if secret.External.External {
+			continue
+		}
+
+		data, err := ioutil.ReadFile(secret.File)
+		if err != nil {
+			return nil, err
+		}
+
+		result = append(result, swarm.SecretSpec{
+			Annotations: swarm.Annotations{
+				Name:   namespace.Scope(name),
+				Labels: AddStackLabel(namespace, secret.Labels),
+			},
+			Data: data,
+		})
+	}
+	return result, nil
+}
diff --git a/cli/compose/convert/compose_test.go b/cli/compose/convert/compose_test.go
index 5417d2d..f333d73f 100644
--- a/cli/compose/convert/compose_test.go
+++ b/cli/compose/convert/compose_test.go
@@ -7,6 +7,7 @@
 	"github.com/docker/docker/api/types/network"
 	composetypes "github.com/docker/docker/cli/compose/types"
 	"github.com/docker/docker/pkg/testutil/assert"
+	"github.com/docker/docker/pkg/testutil/tempfile"
 )
 
 func TestNamespaceScope(t *testing.T) {
@@ -88,3 +89,34 @@
 	assert.DeepEqual(t, networks, expected)
 	assert.DeepEqual(t, externals, []string{"special"})
 }
+
+func TestSecrets(t *testing.T) {
+	namespace := Namespace{name: "foo"}
+
+	secretText := "this is the first secret"
+	secretFile := tempfile.NewTempFile(t, "convert-secrets", secretText)
+	defer secretFile.Remove()
+
+	source := map[string]composetypes.SecretConfig{
+		"one": {
+			File:   secretFile.Name(),
+			Labels: map[string]string{"monster": "mash"},
+		},
+		"ext": {
+			External: composetypes.External{
+				External: true,
+			},
+		},
+	}
+
+	specs, err := Secrets(namespace, source)
+	assert.NilError(t, err)
+	assert.Equal(t, len(specs), 1)
+	secret := specs[0]
+	assert.Equal(t, secret.Name, "foo_one")
+	assert.DeepEqual(t, secret.Labels, map[string]string{
+		"monster":      "mash",
+		LabelNamespace: "foo",
+	})
+	assert.DeepEqual(t, secret.Data, []byte(secretText))
+}
diff --git a/cli/compose/convert/service.go b/cli/compose/convert/service.go
index 05e4fa4..4a54895 100644
--- a/cli/compose/convert/service.go
+++ b/cli/compose/convert/service.go
@@ -2,21 +2,27 @@
 
 import (
 	"fmt"
+	"os"
 	"sort"
 	"time"
 
+	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
 	"github.com/docker/docker/api/types/swarm"
+	servicecli "github.com/docker/docker/cli/command/service"
 	composetypes "github.com/docker/docker/cli/compose/types"
+	"github.com/docker/docker/client"
 	"github.com/docker/docker/opts"
 	runconfigopts "github.com/docker/docker/runconfig/opts"
 	"github.com/docker/go-connections/nat"
 )
 
 // Services from compose-file types to engine API types
+// TODO: fix secrets API so that SecretAPIClient is not required here
 func Services(
 	namespace Namespace,
 	config *composetypes.Config,
+	client client.SecretAPIClient,
 ) (map[string]swarm.ServiceSpec, error) {
 	result := make(map[string]swarm.ServiceSpec)
 
@@ -25,7 +31,12 @@
 	networks := config.Networks
 
 	for _, service := range services {
-		serviceSpec, err := convertService(namespace, service, networks, volumes)
+
+		secrets, err := convertServiceSecrets(client, namespace, service.Secrets, config.Secrets)
+		if err != nil {
+			return nil, err
+		}
+		serviceSpec, err := convertService(namespace, service, networks, volumes, secrets)
 		if err != nil {
 			return nil, err
 		}
@@ -40,6 +51,7 @@
 	service composetypes.ServiceConfig,
 	networkConfigs map[string]composetypes.NetworkConfig,
 	volumes map[string]composetypes.VolumeConfig,
+	secrets []*swarm.SecretReference,
 ) (swarm.ServiceSpec, error) {
 	name := namespace.Scope(service.Name)
 
@@ -109,6 +121,7 @@
 				StopGracePeriod: service.StopGracePeriod,
 				TTY:             service.Tty,
 				OpenStdin:       service.StdinOpen,
+				Secrets:         secrets,
 			},
 			LogDriver:     logDriver,
 			Resources:     resources,
@@ -178,6 +191,47 @@
 	return nets, nil
 }
 
+// TODO: fix secrets API so that SecretAPIClient is not required here
+func convertServiceSecrets(
+	client client.SecretAPIClient,
+	namespace Namespace,
+	secrets []composetypes.ServiceSecretConfig,
+	secretSpecs map[string]composetypes.SecretConfig,
+) ([]*swarm.SecretReference, error) {
+	opts := []*types.SecretRequestOption{}
+	for _, secret := range secrets {
+		target := secret.Target
+		if target == "" {
+			target = secret.Source
+		}
+
+		source := namespace.Scope(secret.Source)
+		secretSpec := secretSpecs[secret.Source]
+		if secretSpec.External.External {
+			source = secretSpec.External.Name
+		}
+
+		uid := secret.UID
+		gid := secret.GID
+		if uid == "" {
+			uid = "0"
+		}
+		if gid == "" {
+			gid = "0"
+		}
+
+		opts = append(opts, &types.SecretRequestOption{
+			Source: source,
+			Target: target,
+			UID:    uid,
+			GID:    gid,
+			Mode:   os.FileMode(secret.Mode),
+		})
+	}
+
+	return servicecli.ParseSecrets(client, opts)
+}
+
 func convertExtraHosts(extraHosts map[string]string) []string {
 	hosts := []string{}
 	for host, ip := range extraHosts {
diff --git a/cli/compose/loader/loader.go b/cli/compose/loader/loader.go
index 9e46b97..39f69a0 100644
--- a/cli/compose/loader/loader.go
+++ b/cli/compose/loader/loader.go
@@ -62,16 +62,11 @@
 		}
 	}
 
-	if err := schema.Validate(configDict); err != nil {
+	if err := schema.Validate(configDict, schema.Version(configDict)); err != nil {
 		return nil, err
 	}
 
 	cfg := types.Config{}
-	version := configDict["version"].(string)
-	if version != "3" && version != "3.0" {
-		return nil, fmt.Errorf(`Unsupported Compose file version: %#v. The only version supported is "3" (or "3.0")`, version)
-	}
-
 	if services, ok := configDict["services"]; ok {
 		servicesConfig, err := interpolation.Interpolate(services.(types.Dict), "service", os.LookupEnv)
 		if err != nil {
@@ -114,6 +109,20 @@
 		cfg.Volumes = volumesMapping
 	}
 
+	if secrets, ok := configDict["secrets"]; ok {
+		secretsConfig, err := interpolation.Interpolate(secrets.(types.Dict), "secret", os.LookupEnv)
+		if err != nil {
+			return nil, err
+		}
+
+		secretsMapping, err := loadSecrets(secretsConfig, configDetails.WorkingDir)
+		if err != nil {
+			return nil, err
+		}
+
+		cfg.Secrets = secretsMapping
+	}
+
 	return &cfg, nil
 }
 
@@ -215,13 +224,15 @@
 ) (interface{}, error) {
 	switch target {
 	case reflect.TypeOf(types.External{}):
-		return transformExternal(source, target, data)
+		return transformExternal(data)
 	case reflect.TypeOf(make(map[string]string, 0)):
 		return transformMapStringString(source, target, data)
 	case reflect.TypeOf(types.UlimitsConfig{}):
-		return transformUlimits(source, target, data)
+		return transformUlimits(data)
 	case reflect.TypeOf(types.UnitBytes(0)):
 		return loadSize(data)
+	case reflect.TypeOf(types.ServiceSecretConfig{}):
+		return transformServiceSecret(data)
 	}
 	switch target.Kind() {
 	case reflect.Struct:
@@ -316,7 +327,7 @@
 		var envVars []string
 
 		for _, file := range envFiles {
-			filePath := path.Join(workingDir, file)
+			filePath := absPath(workingDir, file)
 			fileVars, err := opts.ParseEnvFile(filePath)
 			if err != nil {
 				return err
@@ -346,7 +357,7 @@
 		}
 
 		if strings.HasPrefix(parts[0], ".") {
-			parts[0] = path.Join(workingDir, parts[0])
+			parts[0] = absPath(workingDir, parts[0])
 		}
 		parts[0] = expandUser(parts[0])
 
@@ -364,11 +375,7 @@
 	return path
 }
 
-func transformUlimits(
-	source reflect.Type,
-	target reflect.Type,
-	data interface{},
-) (interface{}, error) {
+func transformUlimits(data interface{}) (interface{}, error) {
 	switch value := data.(type) {
 	case int:
 		return types.UlimitsConfig{Single: value}, nil
@@ -412,6 +419,31 @@
 	return volumes, nil
 }
 
+// TODO: remove duplicate with networks/volumes
+func loadSecrets(source types.Dict, workingDir string) (map[string]types.SecretConfig, error) {
+	secrets := make(map[string]types.SecretConfig)
+	if err := transform(source, &secrets); err != nil {
+		return secrets, err
+	}
+	for name, secret := range secrets {
+		if secret.External.External && secret.External.Name == "" {
+			secret.External.Name = name
+			secrets[name] = secret
+		}
+		if secret.File != "" {
+			secret.File = absPath(workingDir, secret.File)
+		}
+	}
+	return secrets, nil
+}
+
+func absPath(workingDir string, filepath string) string {
+	if path.IsAbs(filepath) {
+		return filepath
+	}
+	return path.Join(workingDir, filepath)
+}
+
 func transformStruct(
 	source reflect.Type,
 	target reflect.Type,
@@ -495,11 +527,7 @@
 	return data, nil
 }
 
-func transformExternal(
-	source reflect.Type,
-	target reflect.Type,
-	data interface{},
-) (interface{}, error) {
+func transformExternal(data interface{}) (interface{}, error) {
 	switch value := data.(type) {
 	case bool:
 		return map[string]interface{}{"external": value}, nil
@@ -512,6 +540,20 @@
 	}
 }
 
+func transformServiceSecret(data interface{}) (interface{}, error) {
+	switch value := data.(type) {
+	case string:
+		return map[string]interface{}{"source": value}, nil
+	case types.Dict:
+		return data, nil
+	case map[string]interface{}:
+		return data, nil
+	default:
+		return data, fmt.Errorf("invalid type %T for external", value)
+	}
+
+}
+
 func toYAMLName(name string) string {
 	nameParts := fieldNameRegexp.FindAllString(name, -1)
 	for i, p := range nameParts {
diff --git a/cli/compose/loader/loader_test.go b/cli/compose/loader/loader_test.go
index e15be7c..f7fee89 100644
--- a/cli/compose/loader/loader_test.go
+++ b/cli/compose/loader/loader_test.go
@@ -163,6 +163,24 @@
 	assert.Equal(t, sampleConfig.Volumes, actual.Volumes)
 }
 
+func TestLoadV31(t *testing.T) {
+	actual, err := loadYAML(`
+version: "3.1"
+services:
+  foo:
+    image: busybox
+    secrets: [super]
+secrets:
+  super:
+    external: true
+`)
+	if !assert.NoError(t, err) {
+		return
+	}
+	assert.Equal(t, len(actual.Services), 1)
+	assert.Equal(t, len(actual.Secrets), 1)
+}
+
 func TestParseAndLoad(t *testing.T) {
 	actual, err := loadYAML(sampleYAML)
 	if !assert.NoError(t, err) {
diff --git a/cli/compose/schema/bindata.go b/cli/compose/schema/bindata.go
index c976509..9486e91 100644
--- a/cli/compose/schema/bindata.go
+++ b/cli/compose/schema/bindata.go
@@ -1,6 +1,7 @@
 // Code generated by go-bindata.
 // sources:
 // data/config_schema_v3.0.json
+// data/config_schema_v3.1.json
 // DO NOT EDIT!
 
 package schema
@@ -88,6 +89,26 @@
 	return a, nil
 }
 
+var _dataConfig_schema_v31Json = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\xec\x1a\xcb\x8e\xdb\x36\xf0\xee\xaf\x10\x94\xdc\xe2\xdd\x4d\xd1\xa0\x40\x73\xeb\xb1\xa7\xf6\xdc\x85\x23\xd0\xd2\x58\x66\x96\x22\x19\x92\x72\xd6\x09\xfc\xef\x05\xf5\x32\x45\x91\x22\x6d\x2b\xd9\x45\xd1\xd3\xae\xc5\x99\xe1\xbc\x67\x38\xe4\xf7\x55\x92\xa4\x6f\x65\xbe\x87\x0a\xa5\x1f\x93\x74\xaf\x14\xff\xf8\xf0\xf0\x59\x32\x7a\xd7\x7e\xbd\x67\xa2\x7c\x28\x04\xda\xa9\xbb\xf7\x1f\x1e\xda\x6f\x6f\xd2\xb5\xc6\xc3\x85\x46\xc9\x19\xdd\xe1\x32\x6b\x57\xb2\xc3\xaf\xf7\xbf\xdc\x6b\xf4\x16\x44\x1d\x39\x68\x20\xb6\xfd\x0c\xb9\x6a\xbf\x09\xf8\x52\x63\x01\x1a\xf9\x31\x3d\x80\x90\x98\xd1\x74\xb3\x5e\xe9\x35\x2e\x18\x07\xa1\x30\xc8\xf4\x63\xa2\x99\x4b\x92\x01\xa4\xff\x60\x90\x95\x4a\x60\x5a\xa6\xcd\xe7\x53\x43\x21\x49\x52\x09\xe2\x80\x73\x83\xc2\xc0\xea\x9b\x87\x33\xfd\x87\x01\x6c\x6d\x53\x35\x98\x6d\xbe\x73\xa4\x14\x08\xfa\xf7\x94\xb7\x66\xf9\xd3\x23\xba\xfb\xf6\xc7\xdd\x3f\xef\xef\x7e\xbf\xcf\xee\x36\xef\xde\x8e\x96\xb5\x7e\x05\xec\xda\xed\x0b\xd8\x61\x8a\x15\x66\x74\xd8\x3f\x1d\x20\x4f\xdd\x7f\xa7\x61\x63\x54\x14\x0d\x30\x22\xa3\xbd\x77\x88\x48\x18\xcb\x4c\x41\x7d\x65\xe2\x29\x24\xf3\x00\xf6\x42\x32\x77\xfb\x3b\x64\x1e\x8b\x73\x60\xa4\xae\x82\x16\xec\xa1\x5e\x48\x98\x76\xfb\x65\xec\x27\x21\x17\xa0\xc2\x2e\xdb\x42\xbd\x98\xc7\xea\xed\x6f\x13\x78\xd5\x0b\x3d\x0b\xdb\x42\x18\x7b\x37\x0c\x8e\xc2\xdb\xa5\x2a\x57\x78\xf9\x75\x35\x28\xcb\xa3\xa5\x02\x38\x61\x47\xfd\xcd\xa3\x8f\x16\xa0\x02\xaa\xd2\x41\x05\x49\x92\x6e\x6b\x4c\x0a\x5b\xa3\x8c\xc2\x5f\x9a\xc4\xa3\xf1\x31\x49\xbe\xdb\x99\xcc\xa0\xd3\xac\x8f\x7e\xf9\x0d\x3e\xac\x7b\x64\x19\xd6\x73\x46\x15\x3c\xab\x46\xa8\xf9\xad\x5b\x15\xb0\xfc\x09\xc4\x0e\x13\x88\xc5\x40\xa2\x94\x33\x2a\x23\x58\xaa\x8c\x89\xac\xc0\xb9\x4a\x4f\x16\xfa\x84\x5e\xd8\x9f\x06\x54\xe3\xd7\x66\xe5\x20\x98\xe6\x88\x67\xa8\x28\x46\x72\x20\x21\xd0\x31\x5d\x27\x29\x56\x50\x49\xb7\x88\x49\x5a\x53\xfc\xa5\x86\x3f\x3b\x10\x25\x6a\xb0\xe9\x16\x82\xf1\xe5\x09\x97\x82\xd5\x3c\xe3\x48\x68\x07\x9b\x57\x7f\x9a\xb3\xaa\x42\x74\x29\xaf\xbb\x44\x8e\x08\xcd\x33\xaa\x10\xa6\x20\x32\x8a\xaa\x90\x23\xe9\xa8\x03\x5a\xc8\xac\x2d\xf8\xb3\x6e\xb4\xcb\x5a\x7c\x69\x11\x18\xaa\xff\xa2\xf6\x28\xe8\x9c\x63\xb7\x64\xb4\x6b\x6b\xde\x52\x0b\x31\x93\x80\x44\xbe\xbf\x12\x9f\x55\x08\xd3\x18\xdd\x01\x55\xe2\xc8\x19\x6e\xfd\xe5\xd5\x39\x02\xd0\x43\x36\xe4\x92\x8b\xd5\x00\xf4\x80\x05\xa3\x55\x1f\x0d\x31\x09\x66\x48\xf2\x1a\xff\x99\x33\x09\xb6\x62\x2c\x01\xcd\xa5\x41\xd4\x91\x4e\x7a\x8c\xc7\x5e\xf0\x75\x92\xd2\xba\xda\x82\xd0\x3d\xec\x08\x72\xc7\x44\x85\x34\xb3\xfd\xde\xc6\xf2\x48\xd3\x0e\xcf\x33\x15\x68\xca\xa0\xcb\x3a\x22\x19\xc1\xf4\x69\x79\x17\x87\x67\x25\x50\xb6\x67\x52\xc5\xe7\x70\x03\x7d\x0f\x88\xa8\x7d\xbe\x87\xfc\x69\x06\xdd\x84\x1a\x61\x33\xa9\x62\x9c\x1c\x57\xa8\x0c\x03\xf1\x3c\x04\x42\xd0\x16\xc8\x55\x72\x2e\xaa\x7c\x83\x2c\x2b\x4b\x0d\xea\xf3\xb8\x49\xe7\xd2\x2d\x87\x6a\x7e\x21\xf0\x01\x44\x6c\x01\x67\xfc\xdc\x70\xd9\x8b\xe1\x06\x24\x09\x77\x9f\x23\xd0\x4f\xf7\x6d\xf3\x39\x13\x55\xcd\x7f\x84\xa4\x1b\xbb\x5d\x48\xac\xba\xef\xfa\x62\x49\x18\xd7\x50\x8c\xac\x52\xa1\x5c\xf7\x0d\x02\xa4\xc7\xae\x67\xd0\xee\x74\x93\x55\xac\xf0\x39\xe8\x04\xd8\xd6\x8d\x37\x53\x5f\x5c\x08\x93\xab\xfa\xc7\x28\xd3\x05\x0f\x10\x01\x69\x7c\xec\xc5\xb2\x79\x66\x37\xec\x62\x0d\x1c\x22\x18\x49\x08\x07\xbb\x57\x91\x23\x6a\x98\x1f\x3e\x44\xfa\x84\x0b\xf7\xb7\x59\x5c\x0f\xaa\x97\x66\x7c\x8f\x1c\x20\x75\x66\xa5\x09\x37\x17\x23\x9b\x40\xb4\xfd\xe0\x16\x9e\xe3\xc2\x9f\x2b\x9a\x0c\x61\x06\x18\x67\x42\x4d\xa2\xeb\xe7\x94\xfb\x76\xeb\x9b\xab\x3d\x17\xf8\x80\x09\x94\x30\x3e\xb5\x6c\x19\x23\x80\xe8\x28\xf5\x08\x40\x45\xc6\x28\x39\x46\x40\x4a\x85\x44\xf0\x40\x21\x21\xaf\x05\x56\xc7\x8c\x71\xb5\x78\x9f\x21\xf7\x55\x26\xf1\x37\x18\x5b\xf3\x9c\xef\x3b\x42\x1b\x8b\x21\x6b\x42\x72\xa5\x41\x7d\x29\x29\x1c\xc6\x8e\x44\x18\x4c\x54\xe1\x14\x95\x4a\x56\x8b\x3c\xf6\x80\xad\xf7\x44\xa2\x84\xd8\x23\xbc\x76\xb7\x71\xd8\xcc\x03\x97\x97\x00\x4f\x0a\x5d\x67\xc2\x50\x55\xb6\x7f\x9b\x79\xe5\xe4\x0c\x7d\x79\x94\xb9\xba\xae\x5b\x93\xaa\xc0\x34\x63\x1c\x68\x30\x36\xa4\x62\x3c\x2b\x05\xca\x21\xe3\x20\x30\x73\xaa\x62\x6d\x46\x7a\x51\x0b\xa4\xf7\x9f\x92\x91\xb8\xa4\x88\x84\xc2\x4c\x55\x7c\x77\xe5\xb1\x52\xa9\x70\xb0\xd7\x04\x57\xd8\x1f\x34\x0e\xaf\x8d\xe8\x00\xda\xea\xef\x2e\xfa\x33\x05\xff\xcc\x29\xa6\x0a\x4a\xed\x26\x53\xa7\x9a\xe9\x39\xe7\x5b\xce\x88\x5e\x73\x8f\xc4\xd8\xa0\x33\x7c\x24\x6d\x60\xee\x94\x1b\xc1\xd5\x89\x3a\xf9\x1a\xdd\x75\x34\xf4\xd6\x1d\x23\x1b\x27\xfc\x45\xc5\xdc\x66\x63\xe3\xad\xa7\xee\xa0\xaa\x65\xf0\x58\xd0\xc0\x50\x39\xd7\xd2\x0e\xa0\xc6\xd0\x7e\xd1\x6a\xa1\xdb\x64\x1d\x04\x05\x76\x73\xbb\xb2\x24\xbb\x60\xec\x6e\x9d\x58\x7b\x02\xae\x79\xb2\x09\x1a\x9c\xbf\xcf\xcf\xb6\x3b\x20\xef\xdc\x19\x4b\xb4\xb5\x26\xae\xae\xe0\xd6\xde\x28\x0e\xe1\x1c\x23\x40\x09\x6c\xd9\xa5\x4f\xd4\x66\x3e\x01\xf9\x3a\xc7\x46\x0a\x57\xc0\x6a\x77\xc1\x5b\x99\xfe\xdd\x21\xa5\xc6\x5c\x3e\x60\x54\x03\xd2\xb6\xe9\xe3\x60\xd4\xbe\xbb\x0c\x1a\x2e\x26\x48\x04\x70\x82\x73\x24\x43\x89\xe8\x86\xf1\x44\xcd\x0b\xa4\x20\x6b\xef\x65\x2f\x4a\xfd\x33\x39\x9f\x23\x81\x08\x01\x82\x65\x15\x93\x43\xd3\x02\x08\x3a\x5e\x55\x3e\x1b\xf4\x1d\xc2\xa4\x16\x90\xa1\x5c\x75\x57\xbf\x01\x9f\x4b\x2b\x46\xb1\x62\xce\x0c\x11\xb7\x65\x85\x9e\xb3\x7e\xdb\x06\x24\xd4\xd9\x8c\x9b\xfa\xd8\xc9\x82\xe1\x09\x6d\xe3\x77\x59\x75\x9e\x31\xd1\xb9\xd6\x7b\x3c\xa6\xdf\x71\x22\xba\x00\xa9\x33\xc9\x30\xf8\x09\xe2\x07\x4b\x4b\x77\xca\xc8\x38\x23\x38\x3f\x2e\x25\x61\xce\x68\xab\xe4\x18\x87\xb8\xd1\x03\xb5\x3b\xe8\x56\xa8\xe2\x2a\x18\xac\x0d\xc2\x57\x4c\x0b\xf6\xf5\x82\x0d\x97\x73\x25\x4e\x50\x0e\x56\xbe\xbb\x55\xd1\x52\x09\x84\xa9\xba\xb8\x9c\xdf\x2a\xd6\x0d\xd5\x7c\xf0\xcf\x40\xd6\x1f\xe0\xc2\xf7\xe8\x9e\x4c\x9f\xf3\x3a\x38\x0d\xac\xa0\x62\xc2\xe9\x80\x0b\x3c\xf4\x08\x89\xd8\x83\x2d\x50\xd5\xa2\xc6\xc7\x1d\x54\xc6\xf8\xf2\xa7\x8d\xf0\x88\x78\x13\x4e\x48\x98\xa3\x6a\xa9\xe8\x88\x1e\xa8\xa7\xce\x1a\x9c\xcc\xcf\x2d\x12\xff\xec\x22\xc4\x75\x98\xf7\x0e\x42\xd6\x5b\xea\x19\x21\x4c\x4f\x19\xae\x5b\xfe\xf8\x63\xca\xc9\x7f\x28\xb9\x2d\xe9\xf5\x77\x61\x1e\xab\x3e\x0e\x3d\xf3\x7a\xd0\xd5\x26\xda\xc4\xde\x8b\xa8\xe5\xf8\x6f\xda\x77\x7b\x44\xe0\xea\xf3\x2f\xec\x04\x6f\x48\x2e\xdd\x8b\xa6\x40\x6e\xe9\xa0\xfe\x4f\x2d\xff\x11\x47\xfc\x79\xfe\xd5\x3d\x20\x0b\xbe\xdc\x6a\xa0\xae\x2e\xce\x11\xcf\x95\x5e\x81\xcd\x5e\xda\x14\xe3\xc1\xa2\x61\x92\xe9\x99\x7f\x4e\x93\xd1\xf7\x69\x1d\xc6\x66\xcc\x86\x0d\xe6\x78\xe3\x3b\xae\x90\x73\x83\xa4\x1e\xc4\x73\xbf\x62\x6d\xda\x29\x71\x5e\xf2\x05\x93\xcd\xfd\xbb\x99\x3e\x60\xee\xde\xfb\x07\x15\xd0\x05\x86\x74\x6e\x9b\x5a\x87\x87\x5e\xbb\xd3\x77\x9b\x9e\xf8\x37\xf0\x27\xaf\x38\xb5\x9c\xf4\x38\x99\x49\x7d\x1f\x0f\x5a\xdb\x17\x98\x9b\x91\x7e\x2c\x90\xf6\x15\x89\x91\xdd\x37\xe6\x79\xca\x67\x46\xe7\xdb\x4e\x7b\xcc\xdb\xbf\xb1\xf4\xdc\x6a\xac\xcc\xbf\xcd\x7b\xd8\xd5\x69\xf5\x6f\x00\x00\x00\xff\xff\xfc\xf3\x11\x6a\x88\x2f\x00\x00")
+
+func dataConfig_schema_v31JsonBytes() ([]byte, error) {
+	return bindataRead(
+		_dataConfig_schema_v31Json,
+		"data/config_schema_v3.1.json",
+	)
+}
+
+func dataConfig_schema_v31Json() (*asset, error) {
+	bytes, err := dataConfig_schema_v31JsonBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "data/config_schema_v3.1.json", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
 // Asset loads and returns the asset for the given name.
 // It returns an error if the asset could not be found or
 // could not be loaded.
@@ -141,6 +162,7 @@
 // _bindata is a table, holding each asset generator, mapped to its name.
 var _bindata = map[string]func() (*asset, error){
 	"data/config_schema_v3.0.json": dataConfig_schema_v30Json,
+	"data/config_schema_v3.1.json": dataConfig_schema_v31Json,
 }
 
 // AssetDir returns the file names below a certain
@@ -185,6 +207,7 @@
 var _bintree = &bintree{nil, map[string]*bintree{
 	"data": &bintree{nil, map[string]*bintree{
 		"config_schema_v3.0.json": &bintree{dataConfig_schema_v30Json, map[string]*bintree{}},
+		"config_schema_v3.1.json": &bintree{dataConfig_schema_v31Json, map[string]*bintree{}},
 	}},
 }}
 
diff --git a/cli/compose/schema/data/config_schema_v3.1.json b/cli/compose/schema/data/config_schema_v3.1.json
new file mode 100644
index 0000000..b703748
--- /dev/null
+++ b/cli/compose/schema/data/config_schema_v3.1.json
@@ -0,0 +1,428 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "id": "config_schema_v3.1.json",
+  "type": "object",
+  "required": ["version"],
+
+  "properties": {
+    "version": {
+      "type": "string"
+    },
+
+    "services": {
+      "id": "#/properties/services",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/service"
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "networks": {
+      "id": "#/properties/networks",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/network"
+        }
+      }
+    },
+
+    "volumes": {
+      "id": "#/properties/volumes",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/volume"
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "secrets": {
+      "id": "#/properties/secrets",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/secret"
+        }
+      },
+      "additionalProperties": false
+    }
+  },
+
+  "additionalProperties": false,
+
+  "definitions": {
+
+    "service": {
+      "id": "#/definitions/service",
+      "type": "object",
+
+      "properties": {
+        "deploy": {"$ref": "#/definitions/deployment"},
+        "build": {
+          "oneOf": [
+            {"type": "string"},
+            {
+              "type": "object",
+              "properties": {
+                "context": {"type": "string"},
+                "dockerfile": {"type": "string"},
+                "args": {"$ref": "#/definitions/list_or_dict"}
+              },
+              "additionalProperties": false
+            }
+          ]
+        },
+        "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "cgroup_parent": {"type": "string"},
+        "command": {
+          "oneOf": [
+            {"type": "string"},
+            {"type": "array", "items": {"type": "string"}}
+          ]
+        },
+        "container_name": {"type": "string"},
+        "depends_on": {"$ref": "#/definitions/list_of_strings"},
+        "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "dns": {"$ref": "#/definitions/string_or_list"},
+        "dns_search": {"$ref": "#/definitions/string_or_list"},
+        "domainname": {"type": "string"},
+        "entrypoint": {
+          "oneOf": [
+            {"type": "string"},
+            {"type": "array", "items": {"type": "string"}}
+          ]
+        },
+        "env_file": {"$ref": "#/definitions/string_or_list"},
+        "environment": {"$ref": "#/definitions/list_or_dict"},
+
+        "expose": {
+          "type": "array",
+          "items": {
+            "type": ["string", "number"],
+            "format": "expose"
+          },
+          "uniqueItems": true
+        },
+
+        "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "extra_hosts": {"$ref": "#/definitions/list_or_dict"},
+        "healthcheck": {"$ref": "#/definitions/healthcheck"},
+        "hostname": {"type": "string"},
+        "image": {"type": "string"},
+        "ipc": {"type": "string"},
+        "labels": {"$ref": "#/definitions/list_or_dict"},
+        "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+
+        "logging": {
+            "type": "object",
+
+            "properties": {
+                "driver": {"type": "string"},
+                "options": {
+                  "type": "object",
+                  "patternProperties": {
+                    "^.+$": {"type": ["string", "number", "null"]}
+                  }
+                }
+            },
+            "additionalProperties": false
+        },
+
+        "mac_address": {"type": "string"},
+        "network_mode": {"type": "string"},
+
+        "networks": {
+          "oneOf": [
+            {"$ref": "#/definitions/list_of_strings"},
+            {
+              "type": "object",
+              "patternProperties": {
+                "^[a-zA-Z0-9._-]+$": {
+                  "oneOf": [
+                    {
+                      "type": "object",
+                      "properties": {
+                        "aliases": {"$ref": "#/definitions/list_of_strings"},
+                        "ipv4_address": {"type": "string"},
+                        "ipv6_address": {"type": "string"}
+                      },
+                      "additionalProperties": false
+                    },
+                    {"type": "null"}
+                  ]
+                }
+              },
+              "additionalProperties": false
+            }
+          ]
+        },
+        "pid": {"type": ["string", "null"]},
+
+        "ports": {
+          "type": "array",
+          "items": {
+            "type": ["string", "number"],
+            "format": "ports"
+          },
+          "uniqueItems": true
+        },
+
+        "privileged": {"type": "boolean"},
+        "read_only": {"type": "boolean"},
+        "restart": {"type": "string"},
+        "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "shm_size": {"type": ["number", "string"]},
+        "secrets": {
+          "type": "array",
+          "items": {
+            "oneOf": [
+              {"type": "string"},
+              {
+                "type": "object",
+                "properties": {
+                  "source": {"type": "string"},
+                  "target": {"type": "string"},
+                  "uid": {"type": "string"},
+                  "gid": {"type": "string"},
+                  "mode": {"type": "number"}
+                }
+              }
+            ]
+          }
+        },
+        "sysctls": {"$ref": "#/definitions/list_or_dict"},
+        "stdin_open": {"type": "boolean"},
+        "stop_grace_period": {"type": "string", "format": "duration"},
+        "stop_signal": {"type": "string"},
+        "tmpfs": {"$ref": "#/definitions/string_or_list"},
+        "tty": {"type": "boolean"},
+        "ulimits": {
+          "type": "object",
+          "patternProperties": {
+            "^[a-z]+$": {
+              "oneOf": [
+                {"type": "integer"},
+                {
+                  "type":"object",
+                  "properties": {
+                    "hard": {"type": "integer"},
+                    "soft": {"type": "integer"}
+                  },
+                  "required": ["soft", "hard"],
+                  "additionalProperties": false
+                }
+              ]
+            }
+          }
+        },
+        "user": {"type": "string"},
+        "userns_mode": {"type": "string"},
+        "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "working_dir": {"type": "string"}
+      },
+      "additionalProperties": false
+    },
+
+    "healthcheck": {
+      "id": "#/definitions/healthcheck",
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "disable": {"type": "boolean"},
+        "interval": {"type": "string"},
+        "retries": {"type": "number"},
+        "test": {
+          "oneOf": [
+            {"type": "string"},
+            {"type": "array", "items": {"type": "string"}}
+          ]
+        },
+        "timeout": {"type": "string"}
+      }
+    },
+    "deployment": {
+      "id": "#/definitions/deployment",
+      "type": ["object", "null"],
+      "properties": {
+        "mode": {"type": "string"},
+        "replicas": {"type": "integer"},
+        "labels": {"$ref": "#/definitions/list_or_dict"},
+        "update_config": {
+          "type": "object",
+          "properties": {
+            "parallelism": {"type": "integer"},
+            "delay": {"type": "string", "format": "duration"},
+            "failure_action": {"type": "string"},
+            "monitor": {"type": "string", "format": "duration"},
+            "max_failure_ratio": {"type": "number"}
+          },
+          "additionalProperties": false
+        },
+        "resources": {
+          "type": "object",
+          "properties": {
+            "limits": {"$ref": "#/definitions/resource"},
+            "reservations": {"$ref": "#/definitions/resource"}
+          }
+        },
+        "restart_policy": {
+          "type": "object",
+          "properties": {
+            "condition": {"type": "string"},
+            "delay": {"type": "string", "format": "duration"},
+            "max_attempts": {"type": "integer"},
+            "window": {"type": "string", "format": "duration"}
+          },
+          "additionalProperties": false
+        },
+        "placement": {
+          "type": "object",
+          "properties": {
+            "constraints": {"type": "array", "items": {"type": "string"}}
+          },
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "resource": {
+      "id": "#/definitions/resource",
+      "type": "object",
+      "properties": {
+        "cpus": {"type": "string"},
+        "memory": {"type": "string"}
+      },
+      "additionalProperties": false
+    },
+
+    "network": {
+      "id": "#/definitions/network",
+      "type": ["object", "null"],
+      "properties": {
+        "driver": {"type": "string"},
+        "driver_opts": {
+          "type": "object",
+          "patternProperties": {
+            "^.+$": {"type": ["string", "number"]}
+          }
+        },
+        "ipam": {
+          "type": "object",
+          "properties": {
+            "driver": {"type": "string"},
+            "config": {
+              "type": "array",
+              "items": {
+                "type": "object",
+                "properties": {
+                  "subnet": {"type": "string"}
+                },
+                "additionalProperties": false
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        "external": {
+          "type": ["boolean", "object"],
+          "properties": {
+            "name": {"type": "string"}
+          },
+          "additionalProperties": false
+        },
+        "internal": {"type": "boolean"},
+        "labels": {"$ref": "#/definitions/list_or_dict"}
+      },
+      "additionalProperties": false
+    },
+
+    "volume": {
+      "id": "#/definitions/volume",
+      "type": ["object", "null"],
+      "properties": {
+        "driver": {"type": "string"},
+        "driver_opts": {
+          "type": "object",
+          "patternProperties": {
+            "^.+$": {"type": ["string", "number"]}
+          }
+        },
+        "external": {
+          "type": ["boolean", "object"],
+          "properties": {
+            "name": {"type": "string"}
+          },
+          "additionalProperties": false
+        },
+        "labels": {"$ref": "#/definitions/list_or_dict"}
+      },
+      "additionalProperties": false
+    },
+
+    "secret": {
+      "id": "#/definitions/secret",
+      "type": "object",
+      "properties": {
+        "file": {"type": "string"},
+        "external": {
+          "type": ["boolean", "object"],
+          "properties": {
+            "name": {"type": "string"}
+          }
+        },
+        "labels": {"$ref": "#/definitions/list_or_dict"}
+      },
+      "additionalProperties": false
+    },
+
+    "string_or_list": {
+      "oneOf": [
+        {"type": "string"},
+        {"$ref": "#/definitions/list_of_strings"}
+      ]
+    },
+
+    "list_of_strings": {
+      "type": "array",
+      "items": {"type": "string"},
+      "uniqueItems": true
+    },
+
+    "list_or_dict": {
+      "oneOf": [
+        {
+          "type": "object",
+          "patternProperties": {
+            ".+": {
+              "type": ["string", "number", "null"]
+            }
+          },
+          "additionalProperties": false
+        },
+        {"type": "array", "items": {"type": "string"}, "uniqueItems": true}
+      ]
+    },
+
+    "constraints": {
+      "service": {
+        "id": "#/definitions/constraints/service",
+        "anyOf": [
+          {"required": ["build"]},
+          {"required": ["image"]}
+        ],
+        "properties": {
+          "build": {
+            "required": ["context"]
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/cli/compose/schema/schema.go b/cli/compose/schema/schema.go
index 6366cab..ae33c77 100644
--- a/cli/compose/schema/schema.go
+++ b/cli/compose/schema/schema.go
@@ -7,9 +7,15 @@
 	"strings"
 	"time"
 
+	"github.com/pkg/errors"
 	"github.com/xeipuuv/gojsonschema"
 )
 
+const (
+	defaultVersion = "1.0"
+	versionField   = "version"
+)
+
 type portsFormatChecker struct{}
 
 func (checker portsFormatChecker) IsFormat(input string) bool {
@@ -30,11 +36,29 @@
 	gojsonschema.FormatCheckers.Add("duration", durationFormatChecker{})
 }
 
+// Version returns the version of the config, defaulting to version 1.0
+func Version(config map[string]interface{}) string {
+	version, ok := config[versionField]
+	if !ok {
+		return defaultVersion
+	}
+	return normalizeVersion(fmt.Sprintf("%v", version))
+}
+
+func normalizeVersion(version string) string {
+	switch version {
+	case "3":
+		return "3.0"
+	default:
+		return version
+	}
+}
+
 // Validate uses the jsonschema to validate the configuration
-func Validate(config map[string]interface{}) error {
-	schemaData, err := Asset("data/config_schema_v3.0.json")
+func Validate(config map[string]interface{}, version string) error {
+	schemaData, err := Asset(fmt.Sprintf("data/config_schema_v%s.json", version))
 	if err != nil {
-		return err
+		return errors.Errorf("unsupported Compose file version: %s", version)
 	}
 
 	schemaLoader := gojsonschema.NewStringLoader(string(schemaData))
diff --git a/cli/compose/schema/schema_test.go b/cli/compose/schema/schema_test.go
index be98f80..0935d40 100644
--- a/cli/compose/schema/schema_test.go
+++ b/cli/compose/schema/schema_test.go
@@ -8,7 +8,35 @@
 
 type dict map[string]interface{}
 
-func TestValid(t *testing.T) {
+func TestValidate(t *testing.T) {
+	config := dict{
+		"version": "3.0",
+		"services": dict{
+			"foo": dict{
+				"image": "busybox",
+			},
+		},
+	}
+
+	assert.NoError(t, Validate(config, "3.0"))
+}
+
+func TestValidateUndefinedTopLevelOption(t *testing.T) {
+	config := dict{
+		"version": "3.0",
+		"helicopters": dict{
+			"foo": dict{
+				"image": "busybox",
+			},
+		},
+	}
+
+	err := Validate(config, "3.0")
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "Additional property helicopters is not allowed")
+}
+
+func TestValidateInvalidVersion(t *testing.T) {
 	config := dict{
 		"version": "2.1",
 		"services": dict{
@@ -18,18 +46,7 @@
 		},
 	}
 
-	assert.NoError(t, Validate(config))
-}
-
-func TestUndefinedTopLevelOption(t *testing.T) {
-	config := dict{
-		"version": "2.1",
-		"helicopters": dict{
-			"foo": dict{
-				"image": "busybox",
-			},
-		},
-	}
-
-	assert.Error(t, Validate(config))
+	err := Validate(config, "2.1")
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "unsupported Compose file version: 2.1")
 }
diff --git a/cli/compose/types/types.go b/cli/compose/types/types.go
index aac1a10..cae7b4a 100644
--- a/cli/compose/types/types.go
+++ b/cli/compose/types/types.go
@@ -71,6 +71,7 @@
 	Services []ServiceConfig
 	Networks map[string]NetworkConfig
 	Volumes  map[string]VolumeConfig
+	Secrets  map[string]SecretConfig
 }
 
 // ServiceConfig is the configuration of one service
@@ -108,6 +109,7 @@
 	Privileged      bool
 	ReadOnly        bool `mapstructure:"read_only"`
 	Restart         string
+	Secrets         []ServiceSecretConfig
 	SecurityOpt     []string       `mapstructure:"security_opt"`
 	StdinOpen       bool           `mapstructure:"stdin_open"`
 	StopGracePeriod *time.Duration `mapstructure:"stop_grace_period"`
@@ -191,6 +193,15 @@
 	Ipv6Address string `mapstructure:"ipv6_address"`
 }
 
+// ServiceSecretConfig is the secret configuration for a service
+type ServiceSecretConfig struct {
+	Source string
+	Target string
+	UID    string
+	GID    string
+	Mode   uint32
+}
+
 // UlimitsConfig the ulimit configuration
 type UlimitsConfig struct {
 	Single int
@@ -233,3 +244,10 @@
 	Name     string
 	External bool
 }
+
+// SecretConfig for a secret
+type SecretConfig struct {
+	File     string
+	External External
+	Labels   map[string]string `compose:"list_or_dict_equals"`
+}
diff --git a/client/interface.go b/client/interface.go
index 00b9ade..924b22b 100644
--- a/client/interface.go
+++ b/client/interface.go
@@ -166,4 +166,5 @@
 	SecretCreate(ctx context.Context, secret swarm.SecretSpec) (types.SecretCreateResponse, error)
 	SecretRemove(ctx context.Context, id string) error
 	SecretInspectWithRaw(ctx context.Context, name string) (swarm.Secret, []byte, error)
+	SecretUpdate(ctx context.Context, id string, version swarm.Version, secret swarm.SecretSpec) error
 }
diff --git a/client/secret_update.go b/client/secret_update.go
new file mode 100644
index 0000000..b94e24a
--- /dev/null
+++ b/client/secret_update.go
@@ -0,0 +1,19 @@
+package client
+
+import (
+	"net/url"
+	"strconv"
+
+	"github.com/docker/docker/api/types/swarm"
+	"golang.org/x/net/context"
+)
+
+// SecretUpdate updates a Secret. Currently, the only part of a secret spec
+// which can be updated is Labels.
+func (cli *Client) SecretUpdate(ctx context.Context, id string, version swarm.Version, secret swarm.SecretSpec) error {
+	query := url.Values{}
+	query.Set("version", strconv.FormatUint(version.Index, 10))
+	resp, err := cli.post(ctx, "/secrets/"+id+"/update", query, secret, nil)
+	ensureReaderClosed(resp)
+	return err
+}
diff --git a/client/secret_update_test.go b/client/secret_update_test.go
new file mode 100644
index 0000000..c620985
--- /dev/null
+++ b/client/secret_update_test.go
@@ -0,0 +1,49 @@
+package client
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strings"
+	"testing"
+
+	"golang.org/x/net/context"
+
+	"github.com/docker/docker/api/types/swarm"
+)
+
+func TestSecretUpdateError(t *testing.T) {
+	client := &Client{
+		client: newMockClient(errorMock(http.StatusInternalServerError, "Server error")),
+	}
+
+	err := client.SecretUpdate(context.Background(), "secret_id", swarm.Version{}, swarm.SecretSpec{})
+	if err == nil || err.Error() != "Error response from daemon: Server error" {
+		t.Fatalf("expected a Server Error, got %v", err)
+	}
+}
+
+func TestSecretUpdate(t *testing.T) {
+	expectedURL := "/secrets/secret_id/update"
+
+	client := &Client{
+		client: newMockClient(func(req *http.Request) (*http.Response, error) {
+			if !strings.HasPrefix(req.URL.Path, expectedURL) {
+				return nil, fmt.Errorf("Expected URL '%s', got '%s'", expectedURL, req.URL)
+			}
+			if req.Method != "POST" {
+				return nil, fmt.Errorf("expected POST method, got %s", req.Method)
+			}
+			return &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       ioutil.NopCloser(bytes.NewReader([]byte("body"))),
+			}, nil
+		}),
+	}
+
+	err := client.SecretUpdate(context.Background(), "secret_id", swarm.Version{}, swarm.SecretSpec{})
+	if err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/docs/api/v1.24.md b/docs/api/v1.24.md
index a5e3efa..0cf4e2a 100644
--- a/docs/api/v1.24.md
+++ b/docs/api/v1.24.md
@@ -4459,7 +4459,11 @@
               "Condition": "any",
               "MaxAttempts": 0
             },
-            "Placement": {}
+            "Placement": {
+              "Constraints": [
+                "node.role == worker"
+              ]
+            }
           },
           "Mode": {
             "Replicated": {
@@ -4575,7 +4579,11 @@
             "max-size": "10M"
           }
         },
-        "Placement": {},
+        "Placement": {
+          "Constraints": [
+            "node.role == worker"
+          ]
+        },
         "Resources": {
           "Limits": {
             "MemoryBytes": 104857600
@@ -4682,7 +4690,8 @@
           is 0, which is ignored).
         - **Window** – Windows is the time window used to evaluate the restart policy (default value is
           0, which is unbounded).
-    - **Placement** – An array of constraints.
+    - **Placement** – Restrictions on where a service can run.
+        - **Constraints** – An array of constraints, e.g. `[ "node.role == manager" ]`.
 - **Mode** – Scheduling mode for the service (`replicated` or `global`, defaults to `replicated`).
 - **UpdateConfig** – Specification for the update strategy of the service.
     - **Parallelism** – Maximum number of tasks to be updated in one iteration (0 means unlimited
@@ -4921,7 +4930,8 @@
           is 0, which is ignored).
         - **Window** – Windows is the time window used to evaluate the restart policy (default value is
           0, which is unbounded).
-    - **Placement** – An array of constraints.
+    - **Placement** – Restrictions on where a service can run.
+        - **Constraints** – An array of constraints, e.g. `[ "node.role == manager" ]`.
 - **Mode** – Scheduling mode for the service (`replicated` or `global`, defaults to `replicated`).
 - **UpdateConfig** – Specification for the update strategy of the service.
     - **Parallelism** – Maximum number of tasks to be updated in one iteration (0 means unlimited
diff --git a/docs/extend/plugin_api.md b/docs/extend/plugin_api.md
index e299a09..693b77a 100644
--- a/docs/extend/plugin_api.md
+++ b/docs/extend/plugin_api.md
@@ -79,7 +79,7 @@
     "InsecureSkipVerify": false,
     "CAFile": "/usr/shared/docker/certs/example-ca.pem",
     "CertFile": "/usr/shared/docker/certs/example-cert.pem",
-    "KeyFile": "/usr/shared/docker/certs/example-key.pem",
+    "KeyFile": "/usr/shared/docker/certs/example-key.pem"
   }
 }
 ```
diff --git a/docs/reference/commandline/cli.md b/docs/reference/commandline/cli.md
index 4128baf..e56fb9f 100644
--- a/docs/reference/commandline/cli.md
+++ b/docs/reference/commandline/cli.md
@@ -69,6 +69,9 @@
   Equates to `--disable-content-trust=false` for build, create, pull, push, run.
 * `DOCKER_CONTENT_TRUST_SERVER` The URL of the Notary server to use. This defaults
   to the same URL as the registry.
+* `DOCKER_HIDE_LEGACY_COMMANDS` When set, Docker hides "legacy" top-level commands (such as `docker rm`, and 
+  `docker pull`) in `docker help` output, and only `Management commands` per object-type (e.g., `docker container`) are
+  printed. This may become the default in a future release, at which point this environment-variable is removed.
 * `DOCKER_TMPDIR` Location for temporary Docker files.
 
 Because Docker is developed using Go, you can also use any environment
diff --git a/docs/reference/commandline/plugin_disable.md b/docs/reference/commandline/plugin_disable.md
index f054fd0..e8d16c4 100644
--- a/docs/reference/commandline/plugin_disable.md
+++ b/docs/reference/commandline/plugin_disable.md
@@ -16,7 +16,7 @@
 # plugin disable
 
 ```markdown
-Usage:  docker plugin disable PLUGIN
+Usage:  docker plugin disable [OPTIONS] PLUGIN
 
 Disable a plugin
 
diff --git a/docs/reference/commandline/plugin_push.md b/docs/reference/commandline/plugin_push.md
index 2747f4c..f869e43 100644
--- a/docs/reference/commandline/plugin_push.md
+++ b/docs/reference/commandline/plugin_push.md
@@ -14,7 +14,7 @@
 -->
 
 ```markdown
-Usage:  docker plugin push PLUGIN[:TAG]
+Usage:  docker plugin push [OPTIONS] PLUGIN[:TAG]
 
 Push a plugin to a registry
 
diff --git a/docs/reference/index.md b/docs/reference/index.md
index 01ac341..f24c342 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -18,4 +18,4 @@
 * [Dockerfile reference](builder.md)
 * [Docker run reference](run.md)
 * [Command line reference](commandline/index.md)
-* [API Reference](api/index.md)
+* [API Reference](https://docs.docker.com/engine/api/)
diff --git a/integration-cli/docker_cli_stack_test.go b/integration-cli/docker_cli_stack_test.go
index 1ff791b..fd9b154 100644
--- a/integration-cli/docker_cli_stack_test.go
+++ b/integration-cli/docker_cli_stack_test.go
@@ -1,15 +1,18 @@
 package main
 
 import (
+	"encoding/json"
 	"io/ioutil"
 	"os"
+	"sort"
+	"strings"
 
+	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/pkg/integration/checker"
 	"github.com/go-check/check"
 )
 
-func (s *DockerSwarmSuite) TestStackRemove(c *check.C) {
-	testRequires(c, ExperimentalDaemon)
+func (s *DockerSwarmSuite) TestStackRemoveUnknown(c *check.C) {
 	d := s.AddDaemon(c, true, true)
 
 	stackArgs := append([]string{"stack", "remove", "UNKNOWN_STACK"})
@@ -19,8 +22,7 @@
 	c.Assert(out, check.Equals, "Nothing found in stack: UNKNOWN_STACK\n")
 }
 
-func (s *DockerSwarmSuite) TestStackTasks(c *check.C) {
-	testRequires(c, ExperimentalDaemon)
+func (s *DockerSwarmSuite) TestStackPSUnknown(c *check.C) {
 	d := s.AddDaemon(c, true, true)
 
 	stackArgs := append([]string{"stack", "ps", "UNKNOWN_STACK"})
@@ -30,8 +32,7 @@
 	c.Assert(out, check.Equals, "Nothing found in stack: UNKNOWN_STACK\n")
 }
 
-func (s *DockerSwarmSuite) TestStackServices(c *check.C) {
-	testRequires(c, ExperimentalDaemon)
+func (s *DockerSwarmSuite) TestStackServicesUnknown(c *check.C) {
 	d := s.AddDaemon(c, true, true)
 
 	stackArgs := append([]string{"stack", "services", "UNKNOWN_STACK"})
@@ -42,7 +43,6 @@
 }
 
 func (s *DockerSwarmSuite) TestStackDeployComposeFile(c *check.C) {
-	testRequires(c, ExperimentalDaemon)
 	d := s.AddDaemon(c, true, true)
 
 	testStackName := "testdeploy"
@@ -54,17 +54,81 @@
 	out, err := d.Cmd(stackArgs...)
 	c.Assert(err, checker.IsNil, check.Commentf(out))
 
-	out, err = d.Cmd([]string{"stack", "ls"}...)
+	out, err = d.Cmd("stack", "ls")
 	c.Assert(err, checker.IsNil)
 	c.Assert(out, check.Equals, "NAME        SERVICES\n"+"testdeploy  2\n")
 
-	out, err = d.Cmd([]string{"stack", "rm", testStackName}...)
+	out, err = d.Cmd("stack", "rm", testStackName)
 	c.Assert(err, checker.IsNil)
-	out, err = d.Cmd([]string{"stack", "ls"}...)
+	out, err = d.Cmd("stack", "ls")
 	c.Assert(err, checker.IsNil)
 	c.Assert(out, check.Equals, "NAME  SERVICES\n")
 }
 
+func (s *DockerSwarmSuite) TestStackDeployWithSecretsTwice(c *check.C) {
+	d := s.AddDaemon(c, true, true)
+
+	out, err := d.Cmd("secret", "create", "outside", "fixtures/secrets/default")
+	c.Assert(err, checker.IsNil, check.Commentf(out))
+
+	testStackName := "testdeploy"
+	stackArgs := []string{
+		"stack", "deploy",
+		"--compose-file", "fixtures/deploy/secrets.yaml",
+		testStackName,
+	}
+	out, err = d.Cmd(stackArgs...)
+	c.Assert(err, checker.IsNil, check.Commentf(out))
+
+	out, err = d.Cmd("service", "inspect", "--format", "{{ json .Spec.TaskTemplate.ContainerSpec.Secrets }}", "testdeploy_web")
+	c.Assert(err, checker.IsNil)
+
+	var refs []swarm.SecretReference
+	c.Assert(json.Unmarshal([]byte(out), &refs), checker.IsNil)
+	c.Assert(refs, checker.HasLen, 3)
+
+	sort.Sort(sortSecrets(refs))
+	c.Assert(refs[0].SecretName, checker.Equals, "outside")
+	c.Assert(refs[1].SecretName, checker.Equals, "testdeploy_special")
+	c.Assert(refs[1].File.Name, checker.Equals, "special")
+	c.Assert(refs[2].SecretName, checker.Equals, "testdeploy_super")
+	c.Assert(refs[2].File.Name, checker.Equals, "foo.txt")
+	c.Assert(refs[2].File.Mode, checker.Equals, os.FileMode(0400))
+
+	// Deploy again to ensure there are no errors when secret hasn't changed
+	out, err = d.Cmd(stackArgs...)
+	c.Assert(err, checker.IsNil, check.Commentf(out))
+}
+
+func (s *DockerSwarmSuite) TestStackRemove(c *check.C) {
+	d := s.AddDaemon(c, true, true)
+
+	stackName := "testdeploy"
+	stackArgs := []string{
+		"stack", "deploy",
+		"--compose-file", "fixtures/deploy/remove.yaml",
+		stackName,
+	}
+	out, err := d.Cmd(stackArgs...)
+	c.Assert(err, checker.IsNil, check.Commentf(out))
+
+	out, err = d.Cmd("stack", "ps", stackName)
+	c.Assert(err, checker.IsNil)
+	c.Assert(strings.Split(strings.TrimSpace(out), "\n"), checker.HasLen, 2)
+
+	out, err = d.Cmd("stack", "rm", stackName)
+	c.Assert(err, checker.IsNil, check.Commentf(out))
+	c.Assert(out, checker.Contains, "Removing service testdeploy_web")
+	c.Assert(out, checker.Contains, "Removing network testdeploy_default")
+	c.Assert(out, checker.Contains, "Removing secret testdeploy_special")
+}
+
+type sortSecrets []swarm.SecretReference
+
+func (s sortSecrets) Len() int           { return len(s) }
+func (s sortSecrets) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
+func (s sortSecrets) Less(i, j int) bool { return s[i].SecretName < s[j].SecretName }
+
 // testDAB is the DAB JSON used for testing.
 // TODO: Use template/text and substitute "Image" with the result of
 // `docker inspect --format '{{index .RepoDigests 0}}' busybox:latest`
diff --git a/integration-cli/fixtures/deploy/remove.yaml b/integration-cli/fixtures/deploy/remove.yaml
new file mode 100644
index 0000000..4ec8cac
--- /dev/null
+++ b/integration-cli/fixtures/deploy/remove.yaml
@@ -0,0 +1,11 @@
+
+version: "3.1"
+services:
+  web:
+    image: busybox@sha256:e4f93f6ed15a0cdd342f5aae387886fba0ab98af0a102da6276eaf24d6e6ade0
+    command: top
+    secrets:
+      - special
+secrets:
+  special:
+    file: fixtures/secrets/default
diff --git a/integration-cli/fixtures/deploy/secrets.yaml b/integration-cli/fixtures/deploy/secrets.yaml
new file mode 100644
index 0000000..6ac92cd
--- /dev/null
+++ b/integration-cli/fixtures/deploy/secrets.yaml
@@ -0,0 +1,20 @@
+
+version: "3.1"
+services:
+  web:
+    image: busybox@sha256:e4f93f6ed15a0cdd342f5aae387886fba0ab98af0a102da6276eaf24d6e6ade0
+    command: top
+    secrets:
+      - special
+      - source: super
+        target: foo.txt
+        mode: 0400
+      - star
+secrets:
+  special:
+    file: fixtures/secrets/default
+  super:
+    file: fixtures/secrets/default
+  star:
+    external:
+      name: outside
diff --git a/integration-cli/fixtures/secrets/default b/integration-cli/fixtures/secrets/default
new file mode 100644
index 0000000..e8ca7d8
--- /dev/null
+++ b/integration-cli/fixtures/secrets/default
@@ -0,0 +1 @@
+this is the secret