Repository: compose-spec/compose-go
Pull request #184 (2021-09-15 07:00:44)

normalize scale -> deploy.replicas, defaults to 1
- set Scale=1 by default before loading the service config from yaml
- normalize `scale` into `deploy.replicas` to conform to the compose specification
- serialize deploy.replicas even if set to `0` (to fix https://github.com/docker/compose/issues/8571)


close https://github.com/docker/compose/issues/8571


Patch:
diff --git a/loader/loader.go b/loader/loader.go
index fc4b72b..746aa23 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -514,7 +514,9 @@ func loadServiceWithExtends(filename, name string, servicesDict map[string]inter
 // LoadService produces a single ServiceConfig from a compose file Dict
 // the serviceDict is not validated if directly used. Use Load() to enable validation
 func LoadService(name string, serviceDict map[string]interface{}, workingDir string, lookupEnv template.Mapping, resolvePaths bool) (*types.ServiceConfig, error) {
-	serviceConfig := &types.ServiceConfig{}
+	serviceConfig := &types.ServiceConfig{
+		Scale: 1,
+	}
 	if err := Transform(serviceDict, serviceConfig); err != nil {
 		return nil, err
 	}
@@ -1005,13 +1007,10 @@ var transformSize TransformerFunc = func(value interface{}) (interface{}, error)
 	switch value := value.(type) {
 	case int:
 		return int64(value), nil
-	case int64, types.UnitBytes:
-		return value, nil
 	case string:
 		return units.RAMInBytes(value)
-	default:
-		return value, errors.Errorf("invalid type for size %T", value)
 	}
+	panic(errors.Errorf("invalid type for size %T", value))
 }
 
 var transformStringToDuration TransformerFunc = func(value interface{}) (interface{}, error) {
diff --git a/loader/merge.go b/loader/merge.go
index 0c7a6dd..07ccdb0 100644
--- a/loader/merge.go
+++ b/loader/merge.go
@@ -108,10 +108,10 @@ func _merge(baseService *types.ServiceConfig, overrideService *types.ServiceConf
 	if err := mergo.Merge(baseService, overrideService, mergo.WithAppendSlice, mergo.WithOverride, mergo.WithTransformers(serviceSpecials)); err != nil {
 		return nil, err
 	}
-	if overrideService.Command != nil {
+	if len(overrideService.Command) > 0 {
 		baseService.Command = overrideService.Command
 	}
-	if overrideService.Entrypoint != nil {
+	if len(overrideService.Entrypoint) > 0 {
 		baseService.Entrypoint = overrideService.Entrypoint
 	}
 	return baseService, nil
diff --git a/loader/normalize.go b/loader/normalize.go
index 30e9d20..3f465b3 100644
--- a/loader/normalize.go
+++ b/loader/normalize.go
@@ -84,17 +84,22 @@ func normalize(project *types.Project, resolvePaths bool) error {
 		}
 		s.Environment = s.Environment.Resolve(fn)
 
-		err := relocateLogDriver(s)
+		err := relocateLogDriver(&s)
 		if err != nil {
 			return err
 		}
 
-		err = relocateLogOpt(s)
+		err = relocateLogOpt(&s)
 		if err != nil {
 			return err
 		}
 
-		err = relocateDockerfile(s)
+		err = relocateDockerfile(&s)
+		if err != nil {
+			return err
+		}
+
+		err = relocateScale(&s)
 		if err != nil {
 			return err
 		}
@@ -107,6 +112,21 @@ func normalize(project *types.Project, resolvePaths bool) error {
 	return nil
 }
 
+func relocateScale(s *types.ServiceConfig) error {
+	scale := uint64(s.Scale)
+	if scale != 1 {
+		logrus.Warn("`scale` is deprecated. Use the `deploy.replicas` element")
+		if s.Deploy == nil {
+			s.Deploy = &types.DeployConfig{}
+		}
+		if s.Deploy.Replicas != nil && *s.Deploy.Replicas != scale {
+			return errors.Wrap(errdefs.ErrInvalid, "can't use both 'scale' (deprecated) and 'deploy.replicas'")
+		}
+		s.Deploy.Replicas = &scale
+	}
+	return nil
+}
+
 func absComposeFiles(composeFiles []string) ([]string, error) {
 	absComposeFiles := make([]string, len(composeFiles))
 	for i, composeFile := range composeFiles {
@@ -193,7 +213,7 @@ func relocateExternalName(project *types.Project) error {
 	return nil
 }
 
-func relocateLogOpt(s types.ServiceConfig) error {
+func relocateLogOpt(s *types.ServiceConfig) error {
 	if len(s.LogOpt) != 0 {
 		logrus.Warn("`log_opts` is deprecated. Use the `logging` element")
 		if s.Logging == nil {
@@ -210,7 +230,7 @@ func relocateLogOpt(s types.ServiceConfig) error {
 	return nil
 }
 
-func relocateLogDriver(s types.ServiceConfig) error {
+func relocateLogDriver(s *types.ServiceConfig) error {
 	if s.LogDriver != "" {
 		logrus.Warn("`log_driver` is deprecated. Use the `logging` element")
 		if s.Logging == nil {
@@ -225,7 +245,7 @@ func relocateLogDriver(s types.ServiceConfig) error {
 	return nil
 }
 
-func relocateDockerfile(s types.ServiceConfig) error {
+func relocateDockerfile(s *types.ServiceConfig) error {
 	if s.Dockerfile != "" {
 		logrus.Warn("`dockerfile` is deprecated. Use the `build` element")
 		if s.Build == nil {
diff --git a/types/types.go b/types/types.go
index f4d5886..23bae0e 100644
--- a/types/types.go
+++ b/types/types.go
@@ -152,7 +152,7 @@ type ServiceConfig struct {
 	ReadOnly        bool                             `mapstructure:"read_only" yaml:"read_only,omitempty" json:"read_only,omitempty"`
 	Restart         string                           `yaml:",omitempty" json:"restart,omitempty"`
 	Runtime         string                           `yaml:",omitempty" json:"runtime,omitempty"`
-	Scale           int                              `yaml:",omitempty" json:"scale,omitempty"`
+	Scale           int                              `yaml:"-" json:"-"`
 	Secrets         []ServiceSecretConfig            `yaml:",omitempty" json:"secrets,omitempty"`
 	SecurityOpt     []string                         `mapstructure:"security_opt" yaml:"security_opt,omitempty" json:"security_opt,omitempty"`
 	ShmSize         UnitBytes                        `mapstructure:"shm_size" yaml:"shm_size,omitempty" json:"shm_size,omitempty"`


Test patch:
diff --git a/compatibility/allowlist_test.go b/compatibility/allowlist_test.go
index 592d3e9..f5951da 100644
--- a/compatibility/allowlist_test.go
+++ b/compatibility/allowlist_test.go
@@ -33,6 +33,7 @@ func TestAllowList(t *testing.T) {
 				"services.network_mode",
 				"services.privileged",
 				"services.networks",
+				"services.scale",
 			},
 		},
 	}
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index a4e0e14..751a553 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -358,6 +358,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 			Privileged: true,
 			ReadOnly:   true,
 			Restart:    types.RestartPolicyAlways,
+			Scale:      1,
 			Secrets: []types.ServiceSecretConfig{
 				{
 					Source: "secret1",
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 9d2e52f..cea5117 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -202,6 +202,7 @@ var sampleConfig = types.Config{
 			Networks: map[string]*types.ServiceNetworkConfig{
 				"with_me": nil,
 			},
+			Scale: 1,
 		},
 		{
 			Name:        "bar",
@@ -210,6 +211,7 @@ var sampleConfig = types.Config{
 			Networks: map[string]*types.ServiceNetworkConfig{
 				"with_ipam": nil,
 			},
+			Scale: 1,
 		},
 	},
 	Networks: map[string]types.NetworkConfig{
@@ -689,6 +691,7 @@ networks:
 				},
 				Privileged: true,
 				ReadOnly:   true,
+				Scale:      1,
 				ShmSize:    types.UnitBytes(2 * 1024 * 1024 * 1024),
 				StdinOpen:  true,
 				Tty:        true,
@@ -1440,6 +1443,7 @@ networks:
 			{
 				Name:  "hello-world",
 				Image: "redis:alpine",
+				Scale: 1,
 				Networks: map[string]*types.ServiceNetworkConfig{
 					"network1": nil,
 					"network3": nil,
@@ -1602,6 +1606,7 @@ secrets:
 						Source: "config",
 					},
 				},
+				Scale: 1,
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "secret",
@@ -1667,6 +1672,7 @@ secrets:
 						Source: "config",
 					},
 				},
+				Scale: 1,
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "secret",
@@ -1741,6 +1747,7 @@ func TestLoadWithExtends(t *testing.T) {
 			},
 			Environment: types.MappingWithEquals{},
 			Networks:    map[string]*types.ServiceNetworkConfig{"default": nil},
+			Scale:       1,
 		},
 	}
 	assert.Check(t, is.DeepEqual(expServices, actual.Services))
diff --git a/loader/merge_test.go b/loader/merge_test.go
index d5f1f90..6bdeb54 100644
--- a/loader/merge_test.go
+++ b/loader/merge_test.go
@@ -215,6 +215,7 @@ func TestLoadLogging(t *testing.T) {
 						Name:        "foo",
 						Logging:     tc.expected,
 						Environment: types.MappingWithEquals{},
+						Scale:       1,
 					},
 				},
 				Networks:   types.Networks{},
@@ -370,6 +371,7 @@ func TestLoadMultipleServicePorts(t *testing.T) {
 						Name:        "foo",
 						Ports:       tc.expected,
 						Environment: types.MappingWithEquals{},
+						Scale:       1,
 					},
 				},
 				Networks:   types.Networks{},
@@ -495,6 +497,7 @@ func TestLoadMultipleSecretsConfig(t *testing.T) {
 						Name:        "foo",
 						Secrets:     tc.expected,
 						Environment: types.MappingWithEquals{},
+						Scale:       1,
 					},
 				},
 				Networks:   types.Networks{},
@@ -620,6 +623,7 @@ func TestLoadMultipleConfigobjsConfig(t *testing.T) {
 						Name:        "foo",
 						Configs:     tc.expected,
 						Environment: types.MappingWithEquals{},
+						Scale:       1,
 					},
 				},
 				Networks:   types.Networks{},
@@ -735,6 +739,7 @@ func TestLoadMultipleUlimits(t *testing.T) {
 						Name:        "foo",
 						Ulimits:     tc.expected,
 						Environment: types.MappingWithEquals{},
+						Scale:       1,
 					},
 				},
 				Networks:   types.Networks{},
@@ -853,6 +858,7 @@ func TestLoadMultipleServiceNetworks(t *testing.T) {
 						Name:        "foo",
 						Networks:    tc.expected,
 						Environment: types.MappingWithEquals{},
+						Scale:       1,
 					},
 				},
 				Networks:   types.Networks{},
@@ -869,9 +875,7 @@ func TestLoadMultipleConfigs(t *testing.T) {
 	base := map[string]interface{}{
 		"services": map[string]interface{}{
 			"foo": map[string]interface{}{
-				"image":      "foo",
-				"entrypoint": "echo",
-				"command":    "hellow world",
+				"image": "foo",
 				"build": map[string]interface{}{
 					"context":    ".",
 					"dockerfile": "bar.Dockerfile",
@@ -896,9 +900,7 @@ func TestLoadMultipleConfigs(t *testing.T) {
 	override := map[string]interface{}{
 		"services": map[string]interface{}{
 			"foo": map[string]interface{}{
-				"image":      "baz",
-				"entrypoint": "ping",
-				"command":    "localhost",
+				"image": "baz",
 				"build": map[string]interface{}{
 					"dockerfile": "foo.Dockerfile",
 					"args": []interface{}{
@@ -944,12 +946,11 @@ func TestLoadMultipleConfigs(t *testing.T) {
 				Name:        "bar",
 				Image:       "bar",
 				Environment: types.MappingWithEquals{},
+				Scale:       1,
 			},
 			{
-				Name:       "foo",
-				Image:      "baz",
-				Entrypoint: types.ShellCommand{"ping"},
-				Command:    types.ShellCommand{"localhost"},
+				Name:  "foo",
+				Image: "baz",
 				Build: &types.BuildConfig{
 					Context:    ".",
 					Dockerfile: "foo.Dockerfile",
@@ -981,6 +982,7 @@ func TestLoadMultipleConfigs(t *testing.T) {
 				},
 				CapAdd:      []string{"NET_ADMIN", "SYS_ADMIN"},
 				Environment: types.MappingWithEquals{},
+				Scale:       1,
 			}},
 		Networks:   types.Networks{},
 		Volumes:    types.Volumes{},
@@ -1044,6 +1046,7 @@ func TestLoadMultipleNetworks(t *testing.T) {
 				Name:        "foo",
 				Image:       "baz",
 				Environment: types.MappingWithEquals{},
+				Scale:       1,
 			}},
 		Networks: map[string]types.NetworkConfig{
 			"hostnet": {
diff --git a/loader/normalize_test.go b/loader/normalize_test.go
index 2f500e6..dcc5858 100644
--- a/loader/normalize_test.go
+++ b/loader/normalize_test.go
@@ -54,6 +54,7 @@ func TestNormalizeNetworkNames(t *testing.T) {
 						"ZOT": nil,
 					},
 				},
+				Scale: 1,
 			},
 		},
 	}
diff --git a/loader/with-version-struct_test.go b/loader/with-version-struct_test.go
index 1ff23f9..cefb4d1 100644
--- a/loader/with-version-struct_test.go
+++ b/loader/with-version-struct_test.go
@@ -42,6 +42,7 @@ func withVersionServices(workingDir, homeDir string) []types.ServiceConfig {
 				"default": nil,
 			},
 			VolumesFrom: []string{"other"},
+			Scale:       1,
 		},
 		{
 			Name: "other",
@@ -52,6 +53,7 @@ func withVersionServices(workingDir, homeDir string) []types.ServiceConfig {
 			Volumes: []types.ServiceVolumeConfig{
 				{Target: "/data", Type: "volume", Volume: &types.ServiceVolumeVolume{}},
 			},
+			Scale: 1,
 		},
 	}
 }