diff --git a/cli/command/stack/kubernetes/deploy.go b/cli/command/stack/kubernetes/deploy.go index f8558e59a1..02215a79c0 100644 --- a/cli/command/stack/kubernetes/deploy.go +++ b/cli/command/stack/kubernetes/deploy.go @@ -5,8 +5,9 @@ import ( "io/ioutil" "path" + "github.com/docker/cli/cli/command/stack/loader" "github.com/docker/cli/cli/command/stack/options" - composeTypes "github.com/docker/cli/cli/compose/types" + composetypes "github.com/docker/cli/cli/compose/types" "github.com/pkg/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" @@ -19,6 +20,17 @@ func RunDeploy(dockerCli *KubeCli, opts options.Deploy) error { if len(opts.Composefiles) == 0 { return errors.Errorf("Please specify only one compose file (with --compose-file).") } + + // Parse the compose file + cfg, version, err := loader.LoadComposefile(dockerCli, opts) + if err != nil { + return err + } + stack, err := LoadStack(opts.Namespace, version, cfg) + if err != nil { + return err + } + // Initialize clients stacks, err := dockerCli.stacks() if err != nil { @@ -36,12 +48,6 @@ func RunDeploy(dockerCli *KubeCli, opts options.Deploy) error { Pods: pods, } - // Parse the compose file - stack, cfg, err := LoadStack(opts.Namespace, opts.Composefiles) - if err != nil { - return err - } - // FIXME(vdemeester) handle warnings server-side if err = IsColliding(services, stack, cfg); err != nil { return err @@ -82,7 +88,7 @@ func RunDeploy(dockerCli *KubeCli, opts options.Deploy) error { } // createFileBasedConfigMaps creates a Kubernetes ConfigMap for each Compose global file-based config. -func createFileBasedConfigMaps(stackName string, globalConfigs map[string]composeTypes.ConfigObjConfig, configMaps corev1.ConfigMapInterface) error { +func createFileBasedConfigMaps(stackName string, globalConfigs map[string]composetypes.ConfigObjConfig, configMaps corev1.ConfigMapInterface) error { for name, config := range globalConfigs { if config.File == "" { continue @@ -102,7 +108,7 @@ func createFileBasedConfigMaps(stackName string, globalConfigs map[string]compos return nil } -func serviceNames(cfg *composeTypes.Config) []string { +func serviceNames(cfg *composetypes.Config) []string { names := []string{} for _, service := range cfg.Services { @@ -113,7 +119,7 @@ func serviceNames(cfg *composeTypes.Config) []string { } // createFileBasedSecrets creates a Kubernetes Secret for each Compose global file-based secret. -func createFileBasedSecrets(stackName string, globalSecrets map[string]composeTypes.SecretConfig, secrets corev1.SecretInterface) error { +func createFileBasedSecrets(stackName string, globalSecrets map[string]composetypes.SecretConfig, secrets corev1.SecretInterface) error { for name, secret := range globalSecrets { if secret.File == "" { continue diff --git a/cli/command/stack/kubernetes/loader.go b/cli/command/stack/kubernetes/loader.go index f24b029c4b..75a5473ab9 100644 --- a/cli/command/stack/kubernetes/loader.go +++ b/cli/command/stack/kubernetes/loader.go @@ -1,170 +1,32 @@ package kubernetes import ( - "bufio" - "io/ioutil" - "os" - "path/filepath" - "strings" - - "github.com/docker/cli/cli/compose/loader" - "github.com/docker/cli/cli/compose/template" composetypes "github.com/docker/cli/cli/compose/types" apiv1beta1 "github.com/docker/cli/kubernetes/compose/v1beta1" - "github.com/pkg/errors" yaml "gopkg.in/yaml.v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// LoadStack loads a stack from a Compose file, with a given name. -// FIXME(vdemeester) remove this and use cli/compose/loader for both swarm and kubernetes -func LoadStack(name string, composeFiles []string) (*apiv1beta1.Stack, *composetypes.Config, error) { - if len(composeFiles) != 1 { - return nil, nil, errors.New("compose-file must be set (and only one)") - } - composeFile := composeFiles[0] - - workingDir, err := os.Getwd() - if err != nil { - return nil, nil, err - } - - composePath := composeFile - if !strings.HasPrefix(composePath, "/") { - composePath = filepath.Join(workingDir, composeFile) - } - - if _, err := os.Stat(composePath); os.IsNotExist(err) { - return nil, nil, errors.Errorf("no compose file found in %s", filepath.Dir(composePath)) - } - - binary, err := ioutil.ReadFile(composePath) - if err != nil { - return nil, nil, errors.Wrap(err, "cannot read compose file") - } - - env := env(workingDir) - return load(name, binary, workingDir, env) +type versionedConfig struct { + composetypes.Config + Version string } -func load(name string, binary []byte, workingDir string, env map[string]string) (*apiv1beta1.Stack, *composetypes.Config, error) { - processed, err := template.Substitute(string(binary), func(key string) (string, bool) { return env[key], true }) - if err != nil { - return nil, nil, errors.Wrap(err, "cannot load compose file") - } - - parsed, err := loader.ParseYAML([]byte(processed)) - if err != nil { - return nil, nil, errors.Wrapf(err, "cannot load compose file") - } - - cfg, err := loader.Load(composetypes.ConfigDetails{ - WorkingDir: workingDir, - ConfigFiles: []composetypes.ConfigFile{ - { - Config: parsed, - }, - }, +// LoadStack loads a stack from a Compose config, with a given name. +func LoadStack(name, version string, cfg *composetypes.Config) (*apiv1beta1.Stack, error) { + res, err := yaml.Marshal(versionedConfig{ + Version: version, + Config: *cfg, }) if err != nil { - return nil, nil, errors.Wrapf(err, "cannot load compose file") + return nil, err } - - result, err := processEnvFiles(processed, parsed, cfg) - if err != nil { - return nil, nil, errors.Wrapf(err, "cannot load compose file") - } - return &apiv1beta1.Stack{ ObjectMeta: metav1.ObjectMeta{ Name: name, }, Spec: apiv1beta1.StackSpec{ - ComposeFile: result, + ComposeFile: string(res), }, - }, cfg, nil -} - -type iMap = map[string]interface{} - -func processEnvFiles(input string, parsed map[string]interface{}, config *composetypes.Config) (string, error) { - changed := false - - for _, svc := range config.Services { - if len(svc.EnvFile) == 0 { - continue - } - // Load() processed the env_file for us, we just need to inject back into - // the intermediate representation - env := iMap{} - for k, v := range svc.Environment { - env[k] = v - } - parsed["services"].(iMap)[svc.Name].(iMap)["environment"] = env - delete(parsed["services"].(iMap)[svc.Name].(iMap), "env_file") - changed = true - } - if !changed { - return input, nil - } - res, err := yaml.Marshal(parsed) - if err != nil { - return "", err - } - return string(res), nil -} - -func env(workingDir string) map[string]string { - // Apply .env file first - config := readEnvFile(filepath.Join(workingDir, ".env")) - - // Apply env variables - for k, v := range envToMap(os.Environ()) { - config[k] = v - } - - return config -} - -func readEnvFile(path string) map[string]string { - config := map[string]string{} - - file, err := os.Open(path) - if err != nil { - return config // Ignore - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(strings.TrimSpace(line), "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - key := parts[0] - value := parts[1] - - config[key] = value - } - } - - return config -} - -func envToMap(env []string) map[string]string { - config := map[string]string{} - - for _, value := range env { - parts := strings.SplitN(value, "=", 2) - - key := parts[0] - value := parts[1] - - config[key] = value - } - - return config + }, nil } diff --git a/cli/command/stack/kubernetes/loader_test.go b/cli/command/stack/kubernetes/loader_test.go deleted file mode 100644 index 8c5f920393..0000000000 --- a/cli/command/stack/kubernetes/loader_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package kubernetes - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPlaceholders(t *testing.T) { - env := map[string]string{ - "TAG": "_latest_", - "K1": "V1", - "K2": "V2", - } - - prefix := "version: '3'\nvolumes:\n data:\n external:\n name: " - var tests = []struct { - input string - expectedOutput string - }{ - {prefix + "BEFORE${TAG}AFTER", prefix + "BEFORE_latest_AFTER"}, - {prefix + "BEFORE${K1}${K2}AFTER", prefix + "BEFOREV1V2AFTER"}, - {prefix + "BEFORE$TAG AFTER", prefix + "BEFORE_latest_ AFTER"}, - {prefix + "BEFORE$$TAG AFTER", prefix + "BEFORE$TAG AFTER"}, - {prefix + "BEFORE $UNKNOWN AFTER", prefix + "BEFORE AFTER"}, - } - - for _, test := range tests { - output, _, err := load("stack", []byte(test.input), ".", env) - require.NoError(t, err) - assert.Equal(t, test.expectedOutput, output.Spec.ComposeFile) - } -} diff --git a/cli/command/stack/loader/loader.go b/cli/command/stack/loader/loader.go new file mode 100644 index 0000000000..6283faa77b --- /dev/null +++ b/cli/command/stack/loader/loader.go @@ -0,0 +1,152 @@ +package loader + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/stack/options" + "github.com/docker/cli/cli/compose/loader" + "github.com/docker/cli/cli/compose/schema" + composetypes "github.com/docker/cli/cli/compose/types" + "github.com/pkg/errors" +) + +// LoadComposefile parse the composefile specified in the cli and returns its Config and version. +func LoadComposefile(dockerCli command.Cli, opts options.Deploy) (*composetypes.Config, string, error) { + configDetails, err := getConfigDetails(opts.Composefiles, dockerCli.In()) + if err != nil { + return nil, "", err + } + + dicts := getDictsFrom(configDetails.ConfigFiles) + config, err := loader.Load(configDetails) + if err != nil { + if fpe, ok := err.(*loader.ForbiddenPropertiesError); ok { + return nil, "", errors.Errorf("Compose file contains unsupported options:\n\n%s\n", + propertyWarnings(fpe.Properties)) + } + + return nil, "", err + } + + unsupportedProperties := loader.GetUnsupportedProperties(dicts...) + if len(unsupportedProperties) > 0 { + fmt.Fprintf(dockerCli.Err(), "Ignoring unsupported options: %s\n\n", + strings.Join(unsupportedProperties, ", ")) + } + + deprecatedProperties := loader.GetDeprecatedProperties(dicts...) + if len(deprecatedProperties) > 0 { + fmt.Fprintf(dockerCli.Err(), "Ignoring deprecated options:\n\n%s\n\n", + propertyWarnings(deprecatedProperties)) + } + return config, configDetails.Version, nil +} + +func getDictsFrom(configFiles []composetypes.ConfigFile) []map[string]interface{} { + dicts := []map[string]interface{}{} + + for _, configFile := range configFiles { + dicts = append(dicts, configFile.Config) + } + + return dicts +} + +func propertyWarnings(properties map[string]string) string { + var msgs []string + for name, description := range properties { + msgs = append(msgs, fmt.Sprintf("%s: %s", name, description)) + } + sort.Strings(msgs) + return strings.Join(msgs, "\n\n") +} + +func getConfigDetails(composefiles []string, stdin io.Reader) (composetypes.ConfigDetails, error) { + var details composetypes.ConfigDetails + + if len(composefiles) == 0 { + return details, errors.New("no composefile(s)") + } + + if composefiles[0] == "-" && len(composefiles) == 1 { + workingDir, err := os.Getwd() + if err != nil { + return details, err + } + details.WorkingDir = workingDir + } else { + absPath, err := filepath.Abs(composefiles[0]) + if err != nil { + return details, err + } + details.WorkingDir = filepath.Dir(absPath) + } + + var err error + details.ConfigFiles, err = loadConfigFiles(composefiles, stdin) + if err != nil { + return details, err + } + // Take the first file version (2 files can't have different version) + details.Version = schema.Version(details.ConfigFiles[0].Config) + details.Environment, err = buildEnvironment(os.Environ()) + return details, err +} + +func buildEnvironment(env []string) (map[string]string, error) { + result := make(map[string]string, len(env)) + for _, s := range env { + // if value is empty, s is like "K=", not "K". + if !strings.Contains(s, "=") { + return result, errors.Errorf("unexpected environment %q", s) + } + kv := strings.SplitN(s, "=", 2) + result[kv[0]] = kv[1] + } + return result, nil +} + +func loadConfigFiles(filenames []string, stdin io.Reader) ([]composetypes.ConfigFile, error) { + var configFiles []composetypes.ConfigFile + + for _, filename := range filenames { + configFile, err := loadConfigFile(filename, stdin) + if err != nil { + return configFiles, err + } + configFiles = append(configFiles, *configFile) + } + + return configFiles, nil +} + +func loadConfigFile(filename string, stdin io.Reader) (*composetypes.ConfigFile, error) { + var bytes []byte + var err error + + if filename == "-" { + bytes, err = ioutil.ReadAll(stdin) + } else { + bytes, err = ioutil.ReadFile(filename) + } + if err != nil { + return nil, err + } + + config, err := loader.ParseYAML(bytes) + if err != nil { + return nil, err + } + + return &composetypes.ConfigFile{ + Filename: filename, + Config: config, + }, nil +} diff --git a/cli/command/stack/loader/loader_test.go b/cli/command/stack/loader/loader_test.go new file mode 100644 index 0000000000..29ccd4e7f5 --- /dev/null +++ b/cli/command/stack/loader/loader_test.go @@ -0,0 +1,47 @@ +package loader + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gotestyourself/gotestyourself/fs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetConfigDetails(t *testing.T) { + content := ` +version: "3.0" +services: + foo: + image: alpine:3.5 +` + file := fs.NewFile(t, "test-get-config-details", fs.WithContent(content)) + defer file.Remove() + + details, err := getConfigDetails([]string{file.Path()}, nil) + require.NoError(t, err) + assert.Equal(t, filepath.Dir(file.Path()), details.WorkingDir) + require.Len(t, details.ConfigFiles, 1) + assert.Equal(t, "3.0", details.ConfigFiles[0].Config["version"]) + assert.Len(t, details.Environment, len(os.Environ())) +} + +func TestGetConfigDetailsStdin(t *testing.T) { + content := ` +version: "3.0" +services: + foo: + image: alpine:3.5 +` + details, err := getConfigDetails([]string{"-"}, strings.NewReader(content)) + require.NoError(t, err) + cwd, err := os.Getwd() + require.NoError(t, err) + assert.Equal(t, cwd, details.WorkingDir) + require.Len(t, details.ConfigFiles, 1) + assert.Equal(t, "3.0", details.ConfigFiles[0].Config["version"]) + assert.Len(t, details.Environment, len(os.Environ())) +} diff --git a/cli/command/stack/swarm/deploy_composefile.go b/cli/command/stack/swarm/deploy_composefile.go index ef3d77a7c6..c45f463f8d 100644 --- a/cli/command/stack/swarm/deploy_composefile.go +++ b/cli/command/stack/swarm/deploy_composefile.go @@ -2,17 +2,11 @@ package swarm import ( "fmt" - "io" - "io/ioutil" - "os" - "path/filepath" - "sort" - "strings" "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/stack/loader" "github.com/docker/cli/cli/command/stack/options" "github.com/docker/cli/cli/compose/convert" - "github.com/docker/cli/cli/compose/loader" composetypes "github.com/docker/cli/cli/compose/types" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -24,34 +18,11 @@ import ( ) func deployCompose(ctx context.Context, dockerCli command.Cli, opts options.Deploy) error { - configDetails, err := getConfigDetails(opts.Composefiles, dockerCli.In()) + config, _, err := loader.LoadComposefile(dockerCli, opts) if err != nil { return err } - config, err := loader.Load(configDetails) - if err != nil { - if fpe, ok := err.(*loader.ForbiddenPropertiesError); ok { - return errors.Errorf("Compose file contains unsupported options:\n\n%s\n", - propertyWarnings(fpe.Properties)) - } - - return err - } - - dicts := getDictsFrom(configDetails.ConfigFiles) - unsupportedProperties := loader.GetUnsupportedProperties(dicts...) - if len(unsupportedProperties) > 0 { - fmt.Fprintf(dockerCli.Err(), "Ignoring unsupported options: %s\n\n", - strings.Join(unsupportedProperties, ", ")) - } - - deprecatedProperties := loader.GetDeprecatedProperties(dicts...) - if len(deprecatedProperties) > 0 { - fmt.Fprintf(dockerCli.Err(), "Ignoring deprecated options:\n\n%s\n\n", - propertyWarnings(deprecatedProperties)) - } - if err := checkDaemonIsSwarmManager(ctx, dockerCli); err != nil { return err } @@ -98,16 +69,6 @@ func deployCompose(ctx context.Context, dockerCli command.Cli, opts options.Depl return deployServices(ctx, dockerCli, services, namespace, opts.SendRegistryAuth, opts.ResolveImage) } -func getDictsFrom(configFiles []composetypes.ConfigFile) []map[string]interface{} { - dicts := []map[string]interface{}{} - - for _, configFile := range configFiles { - dicts = append(dicts, configFile.Config) - } - - return dicts -} - func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} { serviceNetworks := map[string]struct{}{} for _, serviceConfig := range serviceConfigs { @@ -122,96 +83,6 @@ func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) ma return serviceNetworks } -func propertyWarnings(properties map[string]string) string { - var msgs []string - for name, description := range properties { - msgs = append(msgs, fmt.Sprintf("%s: %s", name, description)) - } - sort.Strings(msgs) - return strings.Join(msgs, "\n\n") -} - -func getConfigDetails(composefiles []string, stdin io.Reader) (composetypes.ConfigDetails, error) { - var details composetypes.ConfigDetails - - if len(composefiles) == 0 { - return details, errors.New("no composefile(s)") - } - - if composefiles[0] == "-" && len(composefiles) == 1 { - workingDir, err := os.Getwd() - if err != nil { - return details, err - } - details.WorkingDir = workingDir - } else { - absPath, err := filepath.Abs(composefiles[0]) - if err != nil { - return details, err - } - details.WorkingDir = filepath.Dir(absPath) - } - - var err error - details.ConfigFiles, err = loadConfigFiles(composefiles, stdin) - if err != nil { - return details, err - } - details.Environment, err = buildEnvironment(os.Environ()) - return details, err -} - -func buildEnvironment(env []string) (map[string]string, error) { - result := make(map[string]string, len(env)) - for _, s := range env { - // if value is empty, s is like "K=", not "K". - if !strings.Contains(s, "=") { - return result, errors.Errorf("unexpected environment %q", s) - } - kv := strings.SplitN(s, "=", 2) - result[kv[0]] = kv[1] - } - return result, nil -} - -func loadConfigFiles(filenames []string, stdin io.Reader) ([]composetypes.ConfigFile, error) { - var configFiles []composetypes.ConfigFile - - for _, filename := range filenames { - configFile, err := loadConfigFile(filename, stdin) - if err != nil { - return configFiles, err - } - configFiles = append(configFiles, *configFile) - } - - return configFiles, nil -} - -func loadConfigFile(filename string, stdin io.Reader) (*composetypes.ConfigFile, error) { - var bytes []byte - var err error - - if filename == "-" { - bytes, err = ioutil.ReadAll(stdin) - } else { - bytes, err = ioutil.ReadFile(filename) - } - if err != nil { - return nil, err - } - - config, err := loader.ParseYAML(bytes) - if err != nil { - return nil, err - } - - return &composetypes.ConfigFile{ - Filename: filename, - Config: config, - }, nil -} - func validateExternalNetworks( ctx context.Context, client dockerclient.NetworkAPIClient, diff --git a/cli/command/stack/swarm/deploy_composefile_test.go b/cli/command/stack/swarm/deploy_composefile_test.go index 33c3490b33..7f3133148c 100644 --- a/cli/command/stack/swarm/deploy_composefile_test.go +++ b/cli/command/stack/swarm/deploy_composefile_test.go @@ -1,56 +1,16 @@ package swarm import ( - "os" - "path/filepath" - "strings" "testing" "github.com/docker/cli/internal/test/network" "github.com/docker/cli/internal/test/testutil" "github.com/docker/docker/api/types" - "github.com/gotestyourself/gotestyourself/fs" "github.com/pkg/errors" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/net/context" ) -func TestGetConfigDetails(t *testing.T) { - content := ` -version: "3.0" -services: - foo: - image: alpine:3.5 -` - file := fs.NewFile(t, "test-get-config-details", fs.WithContent(content)) - defer file.Remove() - - details, err := getConfigDetails([]string{file.Path()}, nil) - require.NoError(t, err) - assert.Equal(t, filepath.Dir(file.Path()), details.WorkingDir) - require.Len(t, details.ConfigFiles, 1) - assert.Equal(t, "3.0", details.ConfigFiles[0].Config["version"]) - assert.Len(t, details.Environment, len(os.Environ())) -} - -func TestGetConfigDetailsStdin(t *testing.T) { - content := ` -version: "3.0" -services: - foo: - image: alpine:3.5 -` - details, err := getConfigDetails([]string{"-"}, strings.NewReader(content)) - require.NoError(t, err) - cwd, err := os.Getwd() - require.NoError(t, err) - assert.Equal(t, cwd, details.WorkingDir) - require.Len(t, details.ConfigFiles, 1) - assert.Equal(t, "3.0", details.ConfigFiles[0].Config["version"]) - assert.Len(t, details.Environment, len(os.Environ())) -} - type notFound struct { error } diff --git a/cli/compose/loader/full-example.yml b/cli/compose/loader/full-example.yml index 92a60f8e05..3abd8946d7 100644 --- a/cli/compose/loader/full-example.yml +++ b/cli/compose/loader/full-example.yml @@ -193,7 +193,7 @@ services: ports: - 3000 - - "3000-3005" + - "3001-3005" - "8000:8000" - "9090-9091:8080-8081" - "49100:22" diff --git a/cli/compose/loader/full-struct_test.go b/cli/compose/loader/full-struct_test.go new file mode 100644 index 0000000000..3e73e7553c --- /dev/null +++ b/cli/compose/loader/full-struct_test.go @@ -0,0 +1,390 @@ +package loader + +import ( + "time" + + "github.com/docker/cli/cli/compose/types" +) + +func fullExampleConfig(workingDir, homeDir string) *types.Config { + return &types.Config{ + Services: services(workingDir, homeDir), + Networks: networks(), + Volumes: volumes(), + } +} + +func services(workingDir, homeDir string) []types.ServiceConfig { + return []types.ServiceConfig{ + { + Name: "foo", + + Build: types.BuildConfig{ + Context: "./dir", + Dockerfile: "Dockerfile", + Args: map[string]*string{"foo": strPtr("bar")}, + Target: "foo", + Network: "foo", + CacheFrom: []string{"foo", "bar"}, + Labels: map[string]string{"FOO": "BAR"}, + }, + CapAdd: []string{"ALL"}, + CapDrop: []string{"NET_ADMIN", "SYS_ADMIN"}, + CgroupParent: "m-executor-abcd", + Command: []string{"bundle", "exec", "thin", "-p", "3000"}, + ContainerName: "my-web-container", + DependsOn: []string{"db", "redis"}, + Deploy: types.DeployConfig{ + Mode: "replicated", + Replicas: uint64Ptr(6), + Labels: map[string]string{"FOO": "BAR"}, + UpdateConfig: &types.UpdateConfig{ + Parallelism: uint64Ptr(3), + Delay: time.Duration(10 * time.Second), + FailureAction: "continue", + Monitor: time.Duration(60 * time.Second), + MaxFailureRatio: 0.3, + Order: "start-first", + }, + Resources: types.Resources{ + Limits: &types.Resource{ + NanoCPUs: "0.001", + MemoryBytes: 50 * 1024 * 1024, + }, + Reservations: &types.Resource{ + NanoCPUs: "0.0001", + MemoryBytes: 20 * 1024 * 1024, + GenericResources: []types.GenericResource{ + { + DiscreteResourceSpec: &types.DiscreteGenericResource{ + Kind: "gpu", + Value: 2, + }, + }, + { + DiscreteResourceSpec: &types.DiscreteGenericResource{ + Kind: "ssd", + Value: 1, + }, + }, + }, + }, + }, + RestartPolicy: &types.RestartPolicy{ + Condition: "on-failure", + Delay: durationPtr(5 * time.Second), + MaxAttempts: uint64Ptr(3), + Window: durationPtr(2 * time.Minute), + }, + Placement: types.Placement{ + Constraints: []string{"node=foo"}, + Preferences: []types.PlacementPreferences{ + { + Spread: "node.labels.az", + }, + }, + }, + EndpointMode: "dnsrr", + }, + Devices: []string{"/dev/ttyUSB0:/dev/ttyUSB0"}, + DNS: []string{"8.8.8.8", "9.9.9.9"}, + DNSSearch: []string{"dc1.example.com", "dc2.example.com"}, + DomainName: "foo.com", + Entrypoint: []string{"/code/entrypoint.sh", "-p", "3000"}, + Environment: map[string]*string{ + "FOO": strPtr("foo_from_env_file"), + "BAR": strPtr("bar_from_env_file_2"), + "BAZ": strPtr("baz_from_service_def"), + "QUX": strPtr("qux_from_environment"), + }, + EnvFile: []string{ + "./example1.env", + "./example2.env", + }, + Expose: []string{"3000", "8000"}, + ExternalLinks: []string{ + "redis_1", + "project_db_1:mysql", + "project_db_1:postgresql", + }, + ExtraHosts: []string{ + "somehost:162.242.195.82", + "otherhost:50.31.209.229", + }, + HealthCheck: &types.HealthCheckConfig{ + Test: types.HealthCheckTest([]string{"CMD-SHELL", "echo \"hello world\""}), + Interval: durationPtr(10 * time.Second), + Timeout: durationPtr(1 * time.Second), + Retries: uint64Ptr(5), + StartPeriod: durationPtr(15 * time.Second), + }, + Hostname: "foo", + Image: "redis", + Ipc: "host", + Labels: map[string]string{ + "com.example.description": "Accounting webapp", + "com.example.number": "42", + "com.example.empty-label": "", + }, + Links: []string{ + "db", + "db:database", + "redis", + }, + Logging: &types.LoggingConfig{ + Driver: "syslog", + Options: map[string]string{ + "syslog-address": "tcp://192.168.0.42:123", + }, + }, + MacAddress: "02:42:ac:11:65:43", + NetworkMode: "container:0cfeab0f748b9a743dc3da582046357c6ef497631c1a016d28d2bf9b4f899f7b", + Networks: map[string]*types.ServiceNetworkConfig{ + "some-network": { + Aliases: []string{"alias1", "alias3"}, + Ipv4Address: "", + Ipv6Address: "", + }, + "other-network": { + Ipv4Address: "172.16.238.10", + Ipv6Address: "2001:3984:3989::10", + }, + "other-other-network": nil, + }, + Pid: "host", + Ports: []types.ServicePortConfig{ + //"3000", + { + Mode: "ingress", + Target: 3000, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 3001, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 3002, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 3003, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 3004, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 3005, + Protocol: "tcp", + }, + //"8000:8000", + { + Mode: "ingress", + Target: 8000, + Published: 8000, + Protocol: "tcp", + }, + //"9090-9091:8080-8081", + { + Mode: "ingress", + Target: 8080, + Published: 9090, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 8081, + Published: 9091, + Protocol: "tcp", + }, + //"49100:22", + { + Mode: "ingress", + Target: 22, + Published: 49100, + Protocol: "tcp", + }, + //"127.0.0.1:8001:8001", + { + Mode: "ingress", + Target: 8001, + Published: 8001, + Protocol: "tcp", + }, + //"127.0.0.1:5000-5010:5000-5010", + { + Mode: "ingress", + Target: 5000, + Published: 5000, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5001, + Published: 5001, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5002, + Published: 5002, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5003, + Published: 5003, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5004, + Published: 5004, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5005, + Published: 5005, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5006, + Published: 5006, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5007, + Published: 5007, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5008, + Published: 5008, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5009, + Published: 5009, + Protocol: "tcp", + }, + { + Mode: "ingress", + Target: 5010, + Published: 5010, + Protocol: "tcp", + }, + }, + Privileged: true, + ReadOnly: true, + Restart: "always", + SecurityOpt: []string{ + "label=level:s0:c100,c200", + "label=type:svirt_apache_t", + }, + StdinOpen: true, + StopSignal: "SIGUSR1", + StopGracePeriod: durationPtr(time.Duration(20 * time.Second)), + Tmpfs: []string{"/run", "/tmp"}, + Tty: true, + Ulimits: map[string]*types.UlimitsConfig{ + "nproc": { + Single: 65535, + }, + "nofile": { + Soft: 20000, + Hard: 40000, + }, + }, + User: "someone", + Volumes: []types.ServiceVolumeConfig{ + {Target: "/var/lib/mysql", Type: "volume"}, + {Source: "/opt/data", Target: "/var/lib/mysql", Type: "bind"}, + {Source: workingDir, Target: "/code", Type: "bind"}, + {Source: workingDir + "/static", Target: "/var/www/html", Type: "bind"}, + {Source: homeDir + "/configs", Target: "/etc/configs/", Type: "bind", ReadOnly: true}, + {Source: "datavolume", Target: "/var/lib/mysql", Type: "volume"}, + {Source: workingDir + "/opt", Target: "/opt", Consistency: "cached", Type: "bind"}, + {Target: "/opt", Type: "tmpfs", Tmpfs: &types.ServiceVolumeTmpfs{ + Size: int64(10000), + }}, + }, + WorkingDir: "/code", + }, + } +} + +func networks() map[string]types.NetworkConfig { + return map[string]types.NetworkConfig{ + "some-network": {}, + + "other-network": { + Driver: "overlay", + DriverOpts: map[string]string{ + "foo": "bar", + "baz": "1", + }, + Ipam: types.IPAMConfig{ + Driver: "overlay", + Config: []*types.IPAMPool{ + {Subnet: "172.16.238.0/24"}, + {Subnet: "2001:3984:3989::/64"}, + }, + }, + }, + + "external-network": { + Name: "external-network", + External: types.External{External: true}, + }, + + "other-external-network": { + Name: "my-cool-network", + External: types.External{External: true}, + }, + } +} + +func volumes() map[string]types.VolumeConfig { + return map[string]types.VolumeConfig{ + "some-volume": {}, + "other-volume": { + Driver: "flocker", + DriverOpts: map[string]string{ + "foo": "bar", + "baz": "1", + }, + }, + "another-volume": { + Name: "user_specified_name", + Driver: "vsphere", + DriverOpts: map[string]string{ + "foo": "bar", + "baz": "1", + }, + }, + "external-volume": { + Name: "external-volume", + External: types.External{External: true}, + }, + "other-external-volume": { + Name: "my-cool-volume", + External: types.External{External: true}, + }, + "external-volume3": { + Name: "this-is-volume3", + External: types.External{External: true}, + }, + } +} diff --git a/cli/compose/loader/loader_test.go b/cli/compose/loader/loader_test.go index f3586cfc36..8275627d0a 100644 --- a/cli/compose/loader/loader_test.go +++ b/cli/compose/loader/loader_test.go @@ -842,386 +842,11 @@ func TestFullExample(t *testing.T) { workingDir, err := os.Getwd() require.NoError(t, err) - stopGracePeriod := time.Duration(20 * time.Second) + expectedConfig := fullExampleConfig(workingDir, homeDir) - expectedServiceConfig := types.ServiceConfig{ - Name: "foo", - - Build: types.BuildConfig{ - Context: "./dir", - Dockerfile: "Dockerfile", - Args: map[string]*string{"foo": strPtr("bar")}, - Target: "foo", - Network: "foo", - CacheFrom: []string{"foo", "bar"}, - Labels: map[string]string{"FOO": "BAR"}, - }, - CapAdd: []string{"ALL"}, - CapDrop: []string{"NET_ADMIN", "SYS_ADMIN"}, - CgroupParent: "m-executor-abcd", - Command: []string{"bundle", "exec", "thin", "-p", "3000"}, - ContainerName: "my-web-container", - DependsOn: []string{"db", "redis"}, - Deploy: types.DeployConfig{ - Mode: "replicated", - Replicas: uint64Ptr(6), - Labels: map[string]string{"FOO": "BAR"}, - UpdateConfig: &types.UpdateConfig{ - Parallelism: uint64Ptr(3), - Delay: time.Duration(10 * time.Second), - FailureAction: "continue", - Monitor: time.Duration(60 * time.Second), - MaxFailureRatio: 0.3, - Order: "start-first", - }, - Resources: types.Resources{ - Limits: &types.Resource{ - NanoCPUs: "0.001", - MemoryBytes: 50 * 1024 * 1024, - }, - Reservations: &types.Resource{ - NanoCPUs: "0.0001", - MemoryBytes: 20 * 1024 * 1024, - GenericResources: []types.GenericResource{ - { - DiscreteResourceSpec: &types.DiscreteGenericResource{ - Kind: "gpu", - Value: 2, - }, - }, - { - DiscreteResourceSpec: &types.DiscreteGenericResource{ - Kind: "ssd", - Value: 1, - }, - }, - }, - }, - }, - RestartPolicy: &types.RestartPolicy{ - Condition: "on-failure", - Delay: durationPtr(5 * time.Second), - MaxAttempts: uint64Ptr(3), - Window: durationPtr(2 * time.Minute), - }, - Placement: types.Placement{ - Constraints: []string{"node=foo"}, - Preferences: []types.PlacementPreferences{ - { - Spread: "node.labels.az", - }, - }, - }, - EndpointMode: "dnsrr", - }, - Devices: []string{"/dev/ttyUSB0:/dev/ttyUSB0"}, - DNS: []string{"8.8.8.8", "9.9.9.9"}, - DNSSearch: []string{"dc1.example.com", "dc2.example.com"}, - DomainName: "foo.com", - Entrypoint: []string{"/code/entrypoint.sh", "-p", "3000"}, - Environment: map[string]*string{ - "FOO": strPtr("foo_from_env_file"), - "BAR": strPtr("bar_from_env_file_2"), - "BAZ": strPtr("baz_from_service_def"), - "QUX": strPtr("qux_from_environment"), - }, - EnvFile: []string{ - "./example1.env", - "./example2.env", - }, - Expose: []string{"3000", "8000"}, - ExternalLinks: []string{ - "redis_1", - "project_db_1:mysql", - "project_db_1:postgresql", - }, - ExtraHosts: []string{ - "somehost:162.242.195.82", - "otherhost:50.31.209.229", - }, - HealthCheck: &types.HealthCheckConfig{ - Test: types.HealthCheckTest([]string{"CMD-SHELL", "echo \"hello world\""}), - Interval: durationPtr(10 * time.Second), - Timeout: durationPtr(1 * time.Second), - Retries: uint64Ptr(5), - StartPeriod: durationPtr(15 * time.Second), - }, - Hostname: "foo", - Image: "redis", - Ipc: "host", - Labels: map[string]string{ - "com.example.description": "Accounting webapp", - "com.example.number": "42", - "com.example.empty-label": "", - }, - Links: []string{ - "db", - "db:database", - "redis", - }, - Logging: &types.LoggingConfig{ - Driver: "syslog", - Options: map[string]string{ - "syslog-address": "tcp://192.168.0.42:123", - }, - }, - MacAddress: "02:42:ac:11:65:43", - NetworkMode: "container:0cfeab0f748b9a743dc3da582046357c6ef497631c1a016d28d2bf9b4f899f7b", - Networks: map[string]*types.ServiceNetworkConfig{ - "some-network": { - Aliases: []string{"alias1", "alias3"}, - Ipv4Address: "", - Ipv6Address: "", - }, - "other-network": { - Ipv4Address: "172.16.238.10", - Ipv6Address: "2001:3984:3989::10", - }, - "other-other-network": nil, - }, - Pid: "host", - Ports: []types.ServicePortConfig{ - //"3000", - { - Mode: "ingress", - Target: 3000, - Protocol: "tcp", - }, - //"3000-3005", - { - Mode: "ingress", - Target: 3000, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 3001, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 3002, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 3003, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 3004, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 3005, - Protocol: "tcp", - }, - //"8000:8000", - { - Mode: "ingress", - Target: 8000, - Published: 8000, - Protocol: "tcp", - }, - //"9090-9091:8080-8081", - { - Mode: "ingress", - Target: 8080, - Published: 9090, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 8081, - Published: 9091, - Protocol: "tcp", - }, - //"49100:22", - { - Mode: "ingress", - Target: 22, - Published: 49100, - Protocol: "tcp", - }, - //"127.0.0.1:8001:8001", - { - Mode: "ingress", - Target: 8001, - Published: 8001, - Protocol: "tcp", - }, - //"127.0.0.1:5000-5010:5000-5010", - { - Mode: "ingress", - Target: 5000, - Published: 5000, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5001, - Published: 5001, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5002, - Published: 5002, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5003, - Published: 5003, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5004, - Published: 5004, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5005, - Published: 5005, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5006, - Published: 5006, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5007, - Published: 5007, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5008, - Published: 5008, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5009, - Published: 5009, - Protocol: "tcp", - }, - { - Mode: "ingress", - Target: 5010, - Published: 5010, - Protocol: "tcp", - }, - }, - Privileged: true, - ReadOnly: true, - Restart: "always", - SecurityOpt: []string{ - "label=level:s0:c100,c200", - "label=type:svirt_apache_t", - }, - StdinOpen: true, - StopSignal: "SIGUSR1", - StopGracePeriod: &stopGracePeriod, - Tmpfs: []string{"/run", "/tmp"}, - Tty: true, - Ulimits: map[string]*types.UlimitsConfig{ - "nproc": { - Single: 65535, - }, - "nofile": { - Soft: 20000, - Hard: 40000, - }, - }, - User: "someone", - Volumes: []types.ServiceVolumeConfig{ - {Target: "/var/lib/mysql", Type: "volume"}, - {Source: "/opt/data", Target: "/var/lib/mysql", Type: "bind"}, - {Source: workingDir, Target: "/code", Type: "bind"}, - {Source: workingDir + "/static", Target: "/var/www/html", Type: "bind"}, - {Source: homeDir + "/configs", Target: "/etc/configs/", Type: "bind", ReadOnly: true}, - {Source: "datavolume", Target: "/var/lib/mysql", Type: "volume"}, - {Source: workingDir + "/opt", Target: "/opt", Consistency: "cached", Type: "bind"}, - {Target: "/opt", Type: "tmpfs", Tmpfs: &types.ServiceVolumeTmpfs{ - Size: int64(10000), - }}, - }, - WorkingDir: "/code", - } - - assert.Equal(t, []types.ServiceConfig{expectedServiceConfig}, config.Services) - - expectedNetworkConfig := map[string]types.NetworkConfig{ - "some-network": {}, - - "other-network": { - Driver: "overlay", - DriverOpts: map[string]string{ - "foo": "bar", - "baz": "1", - }, - Ipam: types.IPAMConfig{ - Driver: "overlay", - Config: []*types.IPAMPool{ - {Subnet: "172.16.238.0/24"}, - {Subnet: "2001:3984:3989::/64"}, - }, - }, - }, - - "external-network": { - Name: "external-network", - External: types.External{External: true}, - }, - - "other-external-network": { - Name: "my-cool-network", - External: types.External{External: true}, - }, - } - - assert.Equal(t, expectedNetworkConfig, config.Networks) - - expectedVolumeConfig := map[string]types.VolumeConfig{ - "some-volume": {}, - "other-volume": { - Driver: "flocker", - DriverOpts: map[string]string{ - "foo": "bar", - "baz": "1", - }, - }, - "another-volume": { - Name: "user_specified_name", - Driver: "vsphere", - DriverOpts: map[string]string{ - "foo": "bar", - "baz": "1", - }, - }, - "external-volume": { - Name: "external-volume", - External: types.External{External: true}, - }, - "other-external-volume": { - Name: "my-cool-volume", - External: types.External{External: true}, - }, - "external-volume3": { - Name: "this-is-volume3", - External: types.External{External: true}, - }, - } - - assert.Equal(t, expectedVolumeConfig, config.Volumes) + assert.Equal(t, expectedConfig.Services, config.Services) + assert.Equal(t, expectedConfig.Networks, config.Networks) + assert.Equal(t, expectedConfig.Volumes, config.Volumes) } func TestLoadTmpfsVolume(t *testing.T) { diff --git a/cli/compose/loader/types_test.go b/cli/compose/loader/types_test.go new file mode 100644 index 0000000000..2dee0b65b1 --- /dev/null +++ b/cli/compose/loader/types_test.go @@ -0,0 +1,328 @@ +package loader + +import ( + "testing" + + "github.com/stretchr/testify/assert" + yaml "gopkg.in/yaml.v2" +) + +func TestMarshallConfig(t *testing.T) { + cfg := fullExampleConfig("/foo", "/bar") + expected := `configs: {} +networks: + external-network: + name: external-network + external: true + other-external-network: + name: my-cool-network + external: true + other-network: + driver: overlay + driver_opts: + baz: "1" + foo: bar + ipam: + driver: overlay + config: + - subnet: 172.16.238.0/24 + - subnet: 2001:3984:3989::/64 + some-network: {} +secrets: {} +services: + foo: + build: + context: ./dir + dockerfile: Dockerfile + args: + foo: bar + labels: + FOO: BAR + cache_from: + - foo + - bar + network: foo + target: foo + cap_add: + - ALL + cap_drop: + - NET_ADMIN + - SYS_ADMIN + cgroup_parent: m-executor-abcd + command: + - bundle + - exec + - thin + - -p + - "3000" + container_name: my-web-container + depends_on: + - db + - redis + deploy: + mode: replicated + replicas: 6 + labels: + FOO: BAR + update_config: + parallelism: 3 + delay: 10s + failure_action: continue + monitor: 1m0s + max_failure_ratio: 0.3 + order: start-first + resources: + limits: + cpus: "0.001" + memory: "52428800" + reservations: + cpus: "0.0001" + memory: "20971520" + generic_resources: + - discrete_resource_spec: + kind: gpu + value: 2 + - discrete_resource_spec: + kind: ssd + value: 1 + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + window: 2m0s + placement: + constraints: + - node=foo + preferences: + - spread: node.labels.az + endpoint_mode: dnsrr + devices: + - /dev/ttyUSB0:/dev/ttyUSB0 + dns: + - 8.8.8.8 + - 9.9.9.9 + dns_search: + - dc1.example.com + - dc2.example.com + domainname: foo.com + entrypoint: + - /code/entrypoint.sh + - -p + - "3000" + environment: + BAR: bar_from_env_file_2 + BAZ: baz_from_service_def + FOO: foo_from_env_file + QUX: qux_from_environment + env_file: + - ./example1.env + - ./example2.env + expose: + - "3000" + - "8000" + external_links: + - redis_1 + - project_db_1:mysql + - project_db_1:postgresql + extra_hosts: + - somehost:162.242.195.82 + - otherhost:50.31.209.229 + hostname: foo + healthcheck: + test: + - CMD-SHELL + - echo "hello world" + timeout: 1s + interval: 10s + retries: 5 + start_period: 15s + image: redis + ipc: host + labels: + com.example.description: Accounting webapp + com.example.empty-label: "" + com.example.number: "42" + links: + - db + - db:database + - redis + logging: + driver: syslog + options: + syslog-address: tcp://192.168.0.42:123 + mac_address: 02:42:ac:11:65:43 + network_mode: container:0cfeab0f748b9a743dc3da582046357c6ef497631c1a016d28d2bf9b4f899f7b + networks: + other-network: + ipv4_address: 172.16.238.10 + ipv6_address: 2001:3984:3989::10 + other-other-network: null + some-network: + aliases: + - alias1 + - alias3 + pid: host + ports: + - mode: ingress + target: 3000 + protocol: tcp + - mode: ingress + target: 3001 + protocol: tcp + - mode: ingress + target: 3002 + protocol: tcp + - mode: ingress + target: 3003 + protocol: tcp + - mode: ingress + target: 3004 + protocol: tcp + - mode: ingress + target: 3005 + protocol: tcp + - mode: ingress + target: 8000 + published: 8000 + protocol: tcp + - mode: ingress + target: 8080 + published: 9090 + protocol: tcp + - mode: ingress + target: 8081 + published: 9091 + protocol: tcp + - mode: ingress + target: 22 + published: 49100 + protocol: tcp + - mode: ingress + target: 8001 + published: 8001 + protocol: tcp + - mode: ingress + target: 5000 + published: 5000 + protocol: tcp + - mode: ingress + target: 5001 + published: 5001 + protocol: tcp + - mode: ingress + target: 5002 + published: 5002 + protocol: tcp + - mode: ingress + target: 5003 + published: 5003 + protocol: tcp + - mode: ingress + target: 5004 + published: 5004 + protocol: tcp + - mode: ingress + target: 5005 + published: 5005 + protocol: tcp + - mode: ingress + target: 5006 + published: 5006 + protocol: tcp + - mode: ingress + target: 5007 + published: 5007 + protocol: tcp + - mode: ingress + target: 5008 + published: 5008 + protocol: tcp + - mode: ingress + target: 5009 + published: 5009 + protocol: tcp + - mode: ingress + target: 5010 + published: 5010 + protocol: tcp + privileged: true + read_only: true + restart: always + security_opt: + - label=level:s0:c100,c200 + - label=type:svirt_apache_t + stdin_open: true + stop_grace_period: 20s + stop_signal: SIGUSR1 + tmpfs: + - /run + - /tmp + tty: true + ulimits: + nofile: + soft: 20000 + hard: 40000 + nproc: 65535 + user: someone + volumes: + - type: volume + target: /var/lib/mysql + - type: bind + source: /opt/data + target: /var/lib/mysql + - type: bind + source: /foo + target: /code + - type: bind + source: /foo/static + target: /var/www/html + - type: bind + source: /bar/configs + target: /etc/configs/ + read_only: true + - type: volume + source: datavolume + target: /var/lib/mysql + - type: bind + source: /foo/opt + target: /opt + consistency: cached + - type: tmpfs + target: /opt + tmpfs: + size: 10000 + working_dir: /code +volumes: + another-volume: + name: user_specified_name + driver: vsphere + driver_opts: + baz: "1" + foo: bar + external-volume: + name: external-volume + external: true + external-volume3: + name: this-is-volume3 + external: true + other-external-volume: + name: my-cool-volume + external: true + other-volume: + driver: flocker + driver_opts: + baz: "1" + foo: bar + some-volume: {} +` + + actual, err := yaml.Marshal(cfg) + assert.NoError(t, err) + assert.Equal(t, expected, string(actual)) + + // Make sure the expected still + dict, err := ParseYAML([]byte("version: '3.6'\n" + expected)) + assert.NoError(t, err) + _, err = Load(buildConfigDetails(dict, map[string]string{})) + assert.NoError(t, err) +} diff --git a/cli/compose/types/types.go b/cli/compose/types/types.go index 8922a6d413..2299871cfb 100644 --- a/cli/compose/types/types.go +++ b/cli/compose/types/types.go @@ -1,6 +1,7 @@ package types import ( + "fmt" "time" ) @@ -77,69 +78,86 @@ type Config struct { Configs map[string]ConfigObjConfig } +// MarshalYAML makes Config implement yaml.Marshaller +func (c *Config) MarshalYAML() (interface{}, error) { + m := map[string]interface{}{} + services := map[string]ServiceConfig{} + for _, service := range c.Services { + s := service + s.Name = "" + services[service.Name] = s + } + m["services"] = services + m["networks"] = c.Networks + m["volumes"] = c.Volumes + m["secrets"] = c.Secrets + m["configs"] = c.Configs + return m, nil +} + // ServiceConfig is the configuration of one service type ServiceConfig struct { - Name string + Name string `yaml:",omitempty"` - Build BuildConfig - CapAdd []string `mapstructure:"cap_add"` - CapDrop []string `mapstructure:"cap_drop"` - CgroupParent string `mapstructure:"cgroup_parent"` - Command ShellCommand - Configs []ServiceConfigObjConfig - ContainerName string `mapstructure:"container_name"` - CredentialSpec CredentialSpecConfig `mapstructure:"credential_spec"` - DependsOn []string `mapstructure:"depends_on"` - Deploy DeployConfig - Devices []string - DNS StringList - DNSSearch StringList `mapstructure:"dns_search"` - DomainName string `mapstructure:"domainname"` - Entrypoint ShellCommand - Environment MappingWithEquals - EnvFile StringList `mapstructure:"env_file"` - Expose StringOrNumberList - ExternalLinks []string `mapstructure:"external_links"` - ExtraHosts HostsList `mapstructure:"extra_hosts"` - Hostname string - HealthCheck *HealthCheckConfig - Image string - Ipc string - Labels Labels - Links []string - Logging *LoggingConfig - MacAddress string `mapstructure:"mac_address"` - NetworkMode string `mapstructure:"network_mode"` - Networks map[string]*ServiceNetworkConfig - Pid string - Ports []ServicePortConfig - 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"` - StopSignal string `mapstructure:"stop_signal"` - Tmpfs StringList - Tty bool `mapstructure:"tty"` - Ulimits map[string]*UlimitsConfig - User string - Volumes []ServiceVolumeConfig - WorkingDir string `mapstructure:"working_dir"` - Isolation string `mapstructure:"isolation"` + Build BuildConfig `yaml:",omitempty"` + CapAdd []string `mapstructure:"cap_add" yaml:"cap_add,omitempty"` + CapDrop []string `mapstructure:"cap_drop" yaml:"cap_drop,omitempty"` + CgroupParent string `mapstructure:"cgroup_parent" yaml:"cgroup_parent,omitempty"` + Command ShellCommand `yaml:",omitempty"` + Configs []ServiceConfigObjConfig `yaml:",omitempty"` + ContainerName string `mapstructure:"container_name" yaml:"container_name,omitempty"` + CredentialSpec CredentialSpecConfig `mapstructure:"credential_spec" yaml:"credential_spec,omitempty"` + DependsOn []string `mapstructure:"depends_on" yaml:"depends_on,omitempty"` + Deploy DeployConfig `yaml:",omitempty"` + Devices []string `yaml:",omitempty"` + DNS StringList `yaml:",omitempty"` + DNSSearch StringList `mapstructure:"dns_search" yaml:"dns_search,omitempty"` + DomainName string `mapstructure:"domainname" yaml:"domainname,omitempty"` + Entrypoint ShellCommand `yaml:",omitempty"` + Environment MappingWithEquals `yaml:",omitempty"` + EnvFile StringList `mapstructure:"env_file" yaml:"env_file,omitempty"` + Expose StringOrNumberList `yaml:",omitempty"` + ExternalLinks []string `mapstructure:"external_links" yaml:"external_links,omitempty"` + ExtraHosts HostsList `mapstructure:"extra_hosts" yaml:"extra_hosts,omitempty"` + Hostname string `yaml:",omitempty"` + HealthCheck *HealthCheckConfig `yaml:",omitempty"` + Image string `yaml:",omitempty"` + Ipc string `yaml:",omitempty"` + Labels Labels `yaml:",omitempty"` + Links []string `yaml:",omitempty"` + Logging *LoggingConfig `yaml:",omitempty"` + MacAddress string `mapstructure:"mac_address" yaml:"mac_address,omitempty"` + NetworkMode string `mapstructure:"network_mode" yaml:"network_mode,omitempty"` + Networks map[string]*ServiceNetworkConfig `yaml:",omitempty"` + Pid string `yaml:",omitempty"` + Ports []ServicePortConfig `yaml:",omitempty"` + Privileged bool `yaml:",omitempty"` + ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty"` + Restart string `yaml:",omitempty"` + Secrets []ServiceSecretConfig `yaml:",omitempty"` + SecurityOpt []string `mapstructure:"security_opt" yaml:"security_opt,omitempty"` + StdinOpen bool `mapstructure:"stdin_open" yaml:"stdin_open,omitempty"` + StopGracePeriod *time.Duration `mapstructure:"stop_grace_period" yaml:"stop_grace_period,omitempty"` + StopSignal string `mapstructure:"stop_signal" yaml:"stop_signal,omitempty"` + Tmpfs StringList `yaml:",omitempty"` + Tty bool `mapstructure:"tty" yaml:"tty,omitempty"` + Ulimits map[string]*UlimitsConfig `yaml:",omitempty"` + User string `yaml:",omitempty"` + Volumes []ServiceVolumeConfig `yaml:",omitempty"` + WorkingDir string `mapstructure:"working_dir" yaml:"working_dir,omitempty"` + Isolation string `mapstructure:"isolation" yaml:"isolation,omitempty"` } // BuildConfig is a type for build // using the same format at libcompose: https://github.com/docker/libcompose/blob/master/yaml/build.go#L12 type BuildConfig struct { - Context string - Dockerfile string - Args MappingWithEquals - Labels Labels - CacheFrom StringList `mapstructure:"cache_from"` - Network string - Target string + Context string `yaml:",omitempty"` + Dockerfile string `yaml:",omitempty"` + Args MappingWithEquals `yaml:",omitempty"` + Labels Labels `yaml:",omitempty"` + CacheFrom StringList `mapstructure:"cache_from" yaml:"cache_from,omitempty"` + Network string `yaml:",omitempty"` + Target string `yaml:",omitempty"` } // ShellCommand is a string or list of string args @@ -170,30 +188,30 @@ type HostsList []string // LoggingConfig the logging configuration for a service type LoggingConfig struct { - Driver string - Options map[string]string + Driver string `yaml:",omitempty"` + Options map[string]string `yaml:",omitempty"` } // DeployConfig the deployment configuration for a service type DeployConfig struct { - Mode string - Replicas *uint64 - Labels Labels - UpdateConfig *UpdateConfig `mapstructure:"update_config"` - Resources Resources - RestartPolicy *RestartPolicy `mapstructure:"restart_policy"` - Placement Placement - EndpointMode string `mapstructure:"endpoint_mode"` + Mode string `yaml:",omitempty"` + Replicas *uint64 `yaml:",omitempty"` + Labels Labels `yaml:",omitempty"` + UpdateConfig *UpdateConfig `mapstructure:"update_config" yaml:"update_config,omitempty"` + Resources Resources `yaml:",omitempty"` + RestartPolicy *RestartPolicy `mapstructure:"restart_policy" yaml:"restart_policy,omitempty"` + Placement Placement `yaml:",omitempty"` + EndpointMode string `mapstructure:"endpoint_mode" yaml:"endpoint_mode,omitempty"` } // HealthCheckConfig the healthcheck configuration for a service type HealthCheckConfig struct { - Test HealthCheckTest - Timeout *time.Duration - Interval *time.Duration - Retries *uint64 - StartPeriod *time.Duration `mapstructure:"start_period"` - Disable bool + Test HealthCheckTest `yaml:",omitempty"` + Timeout *time.Duration `yaml:",omitempty"` + Interval *time.Duration `yaml:",omitempty"` + Retries *uint64 `yaml:",omitempty"` + StartPeriod *time.Duration `mapstructure:"start_period" yaml:"start_period,omitempty"` + Disable bool `yaml:",omitempty"` } // HealthCheckTest is the command run to test the health of a service @@ -201,32 +219,32 @@ type HealthCheckTest []string // UpdateConfig the service update configuration type UpdateConfig struct { - Parallelism *uint64 - Delay time.Duration - FailureAction string `mapstructure:"failure_action"` - Monitor time.Duration - MaxFailureRatio float32 `mapstructure:"max_failure_ratio"` - Order string + Parallelism *uint64 `yaml:",omitempty"` + Delay time.Duration `yaml:",omitempty"` + FailureAction string `mapstructure:"failure_action" yaml:"failure_action,omitempty"` + Monitor time.Duration `yaml:",omitempty"` + MaxFailureRatio float32 `mapstructure:"max_failure_ratio" yaml:"max_failure_ratio,omitempty"` + Order string `yaml:",omitempty"` } // Resources the resource limits and reservations type Resources struct { - Limits *Resource - Reservations *Resource + Limits *Resource `yaml:",omitempty"` + Reservations *Resource `yaml:",omitempty"` } // Resource is a resource to be limited or reserved type Resource struct { // TODO: types to convert from units and ratios - NanoCPUs string `mapstructure:"cpus"` - MemoryBytes UnitBytes `mapstructure:"memory"` - GenericResources []GenericResource `mapstructure:"generic_resources"` + NanoCPUs string `mapstructure:"cpus" yaml:"cpus,omitempty"` + MemoryBytes UnitBytes `mapstructure:"memory" yaml:"memory,omitempty"` + GenericResources []GenericResource `mapstructure:"generic_resources" yaml:"generic_resources,omitempty"` } // GenericResource represents a "user defined" resource which can // only be an integer (e.g: SSD=3) for a service type GenericResource struct { - DiscreteResourceSpec *DiscreteGenericResource `mapstructure:"discrete_resource_spec"` + DiscreteResourceSpec *DiscreteGenericResource `mapstructure:"discrete_resource_spec" yaml:"discrete_resource_spec,omitempty"` } // DiscreteGenericResource represents a "user defined" resource which is defined @@ -241,74 +259,79 @@ type DiscreteGenericResource struct { // UnitBytes is the bytes type type UnitBytes int64 +// MarshalYAML makes UnitBytes implement yaml.Marshaller +func (u UnitBytes) MarshalYAML() (interface{}, error) { + return fmt.Sprintf("%d", u), nil +} + // RestartPolicy the service restart policy type RestartPolicy struct { - Condition string - Delay *time.Duration - MaxAttempts *uint64 `mapstructure:"max_attempts"` - Window *time.Duration + Condition string `yaml:",omitempty"` + Delay *time.Duration `yaml:",omitempty"` + MaxAttempts *uint64 `mapstructure:"max_attempts" yaml:"max_attempts,omitempty"` + Window *time.Duration `yaml:",omitempty"` } // Placement constraints for the service type Placement struct { - Constraints []string - Preferences []PlacementPreferences + Constraints []string `yaml:",omitempty"` + Preferences []PlacementPreferences `yaml:",omitempty"` } // PlacementPreferences is the preferences for a service placement type PlacementPreferences struct { - Spread string + Spread string `yaml:",omitempty"` } // ServiceNetworkConfig is the network configuration for a service type ServiceNetworkConfig struct { - Aliases []string - Ipv4Address string `mapstructure:"ipv4_address"` - Ipv6Address string `mapstructure:"ipv6_address"` + Aliases []string `yaml:",omitempty"` + Ipv4Address string `mapstructure:"ipv4_address" yaml:"ipv4_address,omitempty"` + Ipv6Address string `mapstructure:"ipv6_address" yaml:"ipv6_address,omitempty"` } // ServicePortConfig is the port configuration for a service type ServicePortConfig struct { - Mode string - Target uint32 - Published uint32 - Protocol string + Mode string `yaml:",omitempty"` + Target uint32 `yaml:",omitempty"` + Published uint32 `yaml:",omitempty"` + Protocol string `yaml:",omitempty"` } // ServiceVolumeConfig are references to a volume used by a service type ServiceVolumeConfig struct { - Type string - Source string - Target string - ReadOnly bool `mapstructure:"read_only"` - Consistency string - Bind *ServiceVolumeBind - Volume *ServiceVolumeVolume - Tmpfs *ServiceVolumeTmpfs + Type string `yaml:",omitempty"` + Source string `yaml:",omitempty"` + Target string `yaml:",omitempty"` + ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty"` + Consistency string `yaml:",omitempty"` + Bind *ServiceVolumeBind `yaml:",omitempty"` + Volume *ServiceVolumeVolume `yaml:",omitempty"` + Tmpfs *ServiceVolumeTmpfs `yaml:",omitempty"` } // ServiceVolumeBind are options for a service volume of type bind type ServiceVolumeBind struct { - Propagation string + Propagation string `yaml:",omitempty"` } // ServiceVolumeVolume are options for a service volume of type volume type ServiceVolumeVolume struct { - NoCopy bool `mapstructure:"nocopy"` + NoCopy bool `mapstructure:"nocopy" yaml:"nocopy,omitempty"` } // ServiceVolumeTmpfs are options for a service volume of type tmpfs type ServiceVolumeTmpfs struct { - Size int64 + Size int64 `yaml:",omitempty"` } // FileReferenceConfig for a reference to a swarm file object type FileReferenceConfig struct { - Source string - Target string - UID string - GID string - Mode *uint32 + Source string `yaml:",omitempty"` + Target string `yaml:",omitempty"` + UID string `yaml:",omitempty"` + GID string `yaml:",omitempty"` + Mode *uint32 `yaml:",omitempty"` } // ServiceConfigObjConfig is the config obj configuration for a service @@ -319,63 +342,79 @@ type ServiceSecretConfig FileReferenceConfig // UlimitsConfig the ulimit configuration type UlimitsConfig struct { - Single int - Soft int - Hard int + Single int `yaml:",omitempty"` + Soft int `yaml:",omitempty"` + Hard int `yaml:",omitempty"` +} + +// MarshalYAML makes UlimitsConfig implement yaml.Marshaller +func (u *UlimitsConfig) MarshalYAML() (interface{}, error) { + if u.Single != 0 { + return u.Single, nil + } + return u, nil } // NetworkConfig for a network type NetworkConfig struct { - Name string - Driver string - DriverOpts map[string]string `mapstructure:"driver_opts"` - Ipam IPAMConfig - External External - Internal bool - Attachable bool - Labels Labels + Name string `yaml:",omitempty"` + Driver string `yaml:",omitempty"` + DriverOpts map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty"` + Ipam IPAMConfig `yaml:",omitempty"` + External External `yaml:",omitempty"` + Internal bool `yaml:",omitempty"` + Attachable bool `yaml:",omitempty"` + Labels Labels `yaml:",omitempty"` } // IPAMConfig for a network type IPAMConfig struct { - Driver string - Config []*IPAMPool + Driver string `yaml:",omitempty"` + Config []*IPAMPool `yaml:",omitempty"` } // IPAMPool for a network type IPAMPool struct { - Subnet string + Subnet string `yaml:",omitempty"` } // VolumeConfig for a volume type VolumeConfig struct { - Name string - Driver string - DriverOpts map[string]string `mapstructure:"driver_opts"` - External External - Labels Labels + Name string `yaml:",omitempty"` + Driver string `yaml:",omitempty"` + DriverOpts map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty"` + External External `yaml:",omitempty"` + Labels Labels `yaml:",omitempty"` } // External identifies a Volume or Network as a reference to a resource that is // not managed, and should already exist. // External.name is deprecated and replaced by Volume.name type External struct { - Name string - External bool + Name string `yaml:",omitempty"` + External bool `yaml:",omitempty"` +} + +// MarshalYAML makes External implement yaml.Marshaller +func (e External) MarshalYAML() (interface{}, error) { + if e.Name == "" { + return e.External, nil + } + return External{Name: e.Name}, nil } // CredentialSpecConfig for credential spec on Windows type CredentialSpecConfig struct { - File string - Registry string + File string `yaml:",omitempty"` + Registry string `yaml:",omitempty"` } // FileObjectConfig is a config type for a file used by a service type FileObjectConfig struct { - Name string - File string - External External - Labels Labels + Name string `yaml:",omitempty"` + File string `yaml:",omitempty"` + External External `yaml:",omitempty"` + Labels Labels `yaml:",omitempty"` } // SecretConfig for a secret