Repository: compose-spec/compose-go
Pull request #481 (2023-11-03 11:34:25)

make scale a *int as default is 1 (not 0)
as default is not 0, `omitempty` doesn't apply and this require code to manage this default value.
using an `*int` we can distinguish unset value (scale=1) vs service explicitly set to scale=0 (many users rely on this to disable service)


Patch:
diff --git a/loader/normalize.go b/loader/normalize.go
index a9ce955..2d5adaa 100644
--- a/loader/normalize.go
+++ b/loader/normalize.go
@@ -116,11 +116,6 @@ func Normalize(project *types.Project) error {
 			return err
 		}
 
-		err = relocateScale(&s)
-		if err != nil {
-			return err
-		}
-
 		inferImplicitDependencies(&s)
 
 		project.Services[i] = s
@@ -198,21 +193,6 @@ func setIfMissing(d types.DependsOnConfig, service string, dep types.ServiceDepe
 	return d
 }
 
-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
-}
-
 // Resources with no explicit name are actually named by their key in map
 func setNameFromKey(project *types.Project) {
 	for i, n := range project.Networks {
diff --git a/loader/validate.go b/loader/validate.go
index 923c7f6..582a3ea 100644
--- a/loader/validate.go
+++ b/loader/validate.go
@@ -106,6 +106,14 @@ func checkConsistency(project *types.Project) error {
 				return errors.Wrap(errdefs.ErrInvalid, fmt.Sprintf("service %q refers to undefined secret %s", s.Name, secret.Source))
 			}
 		}
+
+		if s.Scale != nil && s.Deploy != nil {
+			if s.Deploy.Replicas != nil && *s.Scale != *s.Deploy.Replicas {
+				return errors.Wrap(errdefs.ErrInvalid, "can't set distinct values on 'scale' and 'deploy.replicas'")
+			}
+			s.Deploy.Replicas = s.Scale
+		}
+
 	}
 
 	for name, secret := range project.Secrets {
diff --git a/transform/services.go b/transform/services.go
index a57fc61..5201e80 100644
--- a/transform/services.go
+++ b/transform/services.go
@@ -22,9 +22,6 @@ import (
 
 func transformService(data any, p tree.Path) (any, error) {
 	value := data.(map[string]any)
-	if _, ok := value["scale"]; !ok {
-		value["scale"] = 1 // TODO(ndeloof) we should make scale a *int
-	}
 	return transformMapping(value, p)
 }
 
diff --git a/types/types.go b/types/types.go
index 079db6f..e3f09d8 100644
--- a/types/types.go
+++ b/types/types.go
@@ -112,7 +112,7 @@ type ServiceConfig struct {
 	ReadOnly        bool                             `yaml:"read_only,omitempty" json:"read_only,omitempty"`
 	Restart         string                           `yaml:"restart,omitempty" json:"restart,omitempty"`
 	Runtime         string                           `yaml:"runtime,omitempty" json:"runtime,omitempty"`
-	Scale           int                              `yaml:"scale,omitempty" json:"scale,omitempty"`
+	Scale           *int                             `yaml:"scale,omitempty" json:"scale,omitempty"`
 	Secrets         []ServiceSecretConfig            `yaml:"secrets,omitempty" json:"secrets,omitempty"`
 	SecurityOpt     []string                         `yaml:"security_opt,omitempty" json:"security_opt,omitempty"`
 	ShmSize         UnitBytes                        `yaml:"shm_size,omitempty" json:"shm_size,omitempty"`
@@ -138,19 +138,10 @@ type ServiceConfig struct {
 func (s ServiceConfig) MarshalYAML() (interface{}, error) {
 	type t ServiceConfig
 	value := t(s)
-	value.Scale = 0 // deprecated, but default value "1" doesn't match omitempty
 	value.Name = "" // set during map to slice conversion, not part of the yaml representation
 	return value, nil
 }
 
-// MarshalJSON makes ServiceConfig implement json.Marshaller
-func (s ServiceConfig) MarshalJSON() ([]byte, error) {
-	type t ServiceConfig
-	value := t(s)
-	value.Scale = 0 // deprecated, but default value "1" doesn't match omitempty
-	return json.Marshal(value)
-}
-
 // NetworksByPriority return the service networks IDs sorted according to Priority
 func (s *ServiceConfig) NetworksByPriority() []string {
 	type key struct {
@@ -323,7 +314,7 @@ type LoggingConfig struct {
 // DeployConfig the deployment configuration for a service
 type DeployConfig struct {
 	Mode           string         `yaml:"mode,omitempty" json:"mode,omitempty"`
-	Replicas       *uint64        `yaml:"replicas,omitempty" json:"replicas,omitempty"`
+	Replicas       *int           `yaml:"replicas,omitempty" json:"replicas,omitempty"`
 	Labels         Labels         `yaml:"labels,omitempty" json:"labels,omitempty"`
 	UpdateConfig   *UpdateConfig  `yaml:"update_config,omitempty" json:"update_config,omitempty"`
 	RollbackConfig *UpdateConfig  `yaml:"rollback_config,omitempty" json:"rollback_config,omitempty"`


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index f7392c1..e699e71 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -100,7 +100,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 			},
 			Deploy: &types.DeployConfig{
 				Mode:     "replicated",
-				Replicas: uint64Ptr(6),
+				Replicas: intPtr(6),
 				Labels:   map[string]string{"FOO": "BAR"},
 				RollbackConfig: &types.UpdateConfig{
 					Parallelism:     uint64Ptr(3),
@@ -387,7 +387,6 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 			Privileged: true,
 			ReadOnly:   true,
 			Restart:    types.RestartPolicyAlways,
-			Scale:      1,
 			Secrets: []types.ServiceSecretConfig{
 				{
 					Source: "secret1",
@@ -444,7 +443,6 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 				DockerfileInline: "FROM alpine\nRUN echo \"hello\" > /world.txt\n",
 			},
 			Environment: types.MappingWithEquals{},
-			Scale:       1,
 		},
 	}
 }
diff --git a/loader/loader_test.go b/loader/loader_test.go
index f0d9a96..1a382ed 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -222,7 +222,6 @@ var sampleConfig = types.Config{
 			Networks: map[string]*types.ServiceNetworkConfig{
 				"with_me": nil,
 			},
-			Scale: 1,
 		},
 		{
 			Name:        "bar",
@@ -231,7 +230,6 @@ var sampleConfig = types.Config{
 			Networks: map[string]*types.ServiceNetworkConfig{
 				"with_ipam": nil,
 			},
-			Scale: 1,
 		},
 	},
 	Networks: map[string]types.NetworkConfig{
@@ -817,7 +815,7 @@ networks:
 					Disable: true,
 				},
 				Deploy: &types.DeployConfig{
-					Replicas: uint64Ptr(555),
+					Replicas: intPtr(555),
 					UpdateConfig: &types.UpdateConfig{
 						Parallelism:     uint64Ptr(555),
 						MaxFailureRatio: 3.14,
@@ -844,7 +842,6 @@ networks:
 				},
 				Privileged:      true,
 				ReadOnly:        true,
-				Scale:           1,
 				ShmSize:         types.UnitBytes(2 * 1024 * 1024 * 1024),
 				StopGracePeriod: &typesDuration,
 				StdinOpen:       true,
@@ -1054,7 +1051,7 @@ services:
 `, map[string]string{"FOO_SCALE": "2"})
 
 	assert.NilError(t, err)
-	assert.Equal(t, project.Services[0].Scale, 2)
+	assert.Equal(t, *project.Services[0].Scale, 2)
 }
 
 func durationPtr(value time.Duration) *types.Duration {
@@ -1062,6 +1059,10 @@ func durationPtr(value time.Duration) *types.Duration {
 	return &result
 }
 
+func intPtr(value int) *int {
+	return &value
+}
+
 func uint64Ptr(value uint64) *uint64 {
 	return &value
 }
@@ -1583,7 +1584,6 @@ networks:
 			{
 				Name:  "hello-world",
 				Image: "redis:alpine",
-				Scale: 1,
 				Networks: map[string]*types.ServiceNetworkConfig{
 					"network1": nil,
 					"network3": nil,
@@ -1634,7 +1634,6 @@ networks:
 			{
 				Name:  "foo",
 				Image: "alpine",
-				Scale: 1,
 				Networks: map[string]*types.ServiceNetworkConfig{
 					"network1": {
 						Ipv4Address: "10.1.0.100",
@@ -1796,7 +1795,6 @@ secrets:
 						Source: "config",
 					},
 				},
-				Scale: 1,
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "secret",
@@ -1866,7 +1864,6 @@ secrets:
 						Source: "config",
 					},
 				},
-				Scale: 1,
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "secret",
@@ -1952,7 +1949,6 @@ func TestLoadWithExtends(t *testing.T) {
 				Target: "/var/lib/mysql",
 				Bind:   &types.ServiceVolumeBind{CreateHostPath: true},
 			}},
-			Scale: 1,
 		},
 	}
 	assert.Check(t, is.DeepEqual(expServices, actual.Services))
@@ -1982,7 +1978,6 @@ func TestLoadWithExtendsWithContextUrl(t *testing.T) {
 			},
 			Environment: types.MappingWithEquals{},
 			Networks:    map[string]*types.ServiceNetworkConfig{"default": nil},
-			Scale:       1,
 		},
 	}
 	assert.Check(t, is.DeepEqual(expServices, actual.Services))
@@ -2348,7 +2343,6 @@ func TestDeviceWriteBps(t *testing.T) {
 			Name:        "foo",
 			Image:       "busybox",
 			Environment: types.MappingWithEquals{},
-			Scale:       1,
 			BlkioConfig: &types.BlkioConfig{
 				DeviceReadBps: []types.ThrottleDevice{
 					{
@@ -2392,7 +2386,6 @@ volumes:
 			Name:        "foo",
 			Image:       "busybox",
 			Environment: types.MappingWithEquals{},
-			Scale:       1,
 			Volumes: []types.ServiceVolumeConfig{
 				{
 					Type:   types.VolumeTypeVolume,
@@ -2418,7 +2411,6 @@ services:
 			Name:        "x-foo",
 			Image:       "busybox",
 			Environment: types.MappingWithEquals{},
-			Scale:       1,
 		},
 	})
 }
@@ -2448,7 +2440,6 @@ services:
 			Name:        "foo",
 			Image:       "busybox",
 			Environment: types.MappingWithEquals{},
-			Scale:       1,
 			DependsOn:   types.DependsOnConfig{"imported": {Condition: "service_started", Required: true}},
 		},
 		{
@@ -2459,7 +2450,6 @@ services:
 				filepath.Join(workingDir, "testdata", "subdir", "extra.env"),
 			},
 			Image: "nginx",
-			Scale: 1,
 			Volumes: []types.ServiceVolumeConfig{
 				{
 					Type:   "bind",
@@ -2586,7 +2576,6 @@ services:
 			Name:        "foo",
 			Image:       "nginx",
 			Environment: types.MappingWithEquals{},
-			Scale:       1,
 			DependsOn: types.DependsOnConfig{
 				"bar": {Condition: types.ServiceConditionStarted, Required: true},
 				"baz": {Condition: types.ServiceConditionHealthy, Required: false},
@@ -2640,7 +2629,6 @@ services:
 			EnvFile: types.StringList{
 				filepath.Join(config.WorkingDir, "testdata", "remote", "env"),
 			},
-			Scale: 1,
 			Volumes: []types.ServiceVolumeConfig{
 				{
 					Type:   types.VolumeTypeBind,
@@ -2833,8 +2821,12 @@ services:
     memswap_limit: 640kb
 `)
 	assert.NilError(t, err)
-	assert.Equal(t, project.Services[0].MemSwapLimit, types.UnitBytes(-1))
-	assert.Equal(t, project.Services[1].MemSwapLimit, types.UnitBytes(640*1024))
+	test1, err := project.GetService("test1")
+	assert.NilError(t, err)
+	assert.Equal(t, test1.MemSwapLimit, types.UnitBytes(-1))
+	test2, err := project.GetService("test2")
+	assert.NilError(t, err)
+	assert.Equal(t, test2.MemSwapLimit, types.UnitBytes(640*1024))
 }
 
 func TestBuildUlimits(t *testing.T) {
diff --git a/loader/loader_yaml_test.go b/loader/loader_yaml_test.go
index c02129b..75ae2e8 100644
--- a/loader/loader_yaml_test.go
+++ b/loader/loader_yaml_test.go
@@ -51,7 +51,6 @@ services:
 				"image":   "bar",
 				"command": "echo world",
 				"init":    false,
-				"scale":   1,
 			},
 		},
 	})
diff --git a/loader/normalize_test.go b/loader/normalize_test.go
index f7bf793..9ea4a37 100644
--- a/loader/normalize_test.go
+++ b/loader/normalize_test.go
@@ -52,7 +52,6 @@ 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 cb9d035..a713da3 100644
--- a/loader/with-version-struct_test.go
+++ b/loader/with-version-struct_test.go
@@ -45,7 +45,6 @@ func withVersionServices() []types.ServiceConfig {
 				"default": nil,
 			},
 			VolumesFrom: []string{"other"},
-			Scale:       1,
 		},
 		{
 			Name: "other",
@@ -56,7 +55,6 @@ func withVersionServices() []types.ServiceConfig {
 			Volumes: []types.ServiceVolumeConfig{
 				{Target: "/data", Type: "volume", Volume: &types.ServiceVolumeVolume{}},
 			},
-			Scale: 1,
 		},
 	}
 }