Repository: compose-spec/compose-go
Pull request #604 (2024-03-20 08:51:05)

enforce limits are consistent with deploy section
Introduce `NanoCPUs` to manage "string or number"
Enforce consistent values between limits declared by service and `deploy` section

see https://github.com/compose-spec/compose-spec/pull/475
closes https://github.com/docker/compose/issues/11634


Patch:
diff --git a/cli/options.go b/cli/options.go
index 6680939..2c5ee1a 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -217,7 +217,10 @@ func WithLoadOptions(loadOptions ...func(*loader.Options)) ProjectOptionsFn {
 // profiles specified via the COMPOSE_PROFILES environment variable otherwise.
 func WithDefaultProfiles(profile ...string) ProjectOptionsFn {
 	if len(profile) == 0 {
-		profile = strings.Split(os.Getenv(consts.ComposeProfiles), ",")
+		for _, s := range strings.Split(os.Getenv(consts.ComposeProfiles), ",") {
+			profile = append(profile, strings.TrimSpace(s))
+		}
+
 	}
 	return WithProfiles(profile)
 }
diff --git a/loader/loader.go b/loader/loader.go
index 1714889..1ed3977 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -554,6 +554,10 @@ func modelToProject(dict map[string]interface{}, opts *Options, configDetails ty
 		}
 	}
 
+	if project, err = project.WithProfiles(opts.Profiles); err != nil {
+		return nil, err
+	}
+
 	if !opts.SkipConsistencyCheck {
 		err := checkConsistency(project)
 		if err != nil {
@@ -561,10 +565,6 @@ func modelToProject(dict map[string]interface{}, opts *Options, configDetails ty
 		}
 	}
 
-	if project, err = project.WithProfiles(opts.Profiles); err != nil {
-		return nil, err
-	}
-
 	if !opts.SkipResolveEnvironment {
 		project, err = project.WithServicesEnvironmentResolved(opts.discardEnvFiles)
 		if err != nil {
diff --git a/loader/validate.go b/loader/validate.go
index 025a660..6f7750e 100644
--- a/loader/validate.go
+++ b/loader/validate.go
@@ -117,6 +117,31 @@ func checkConsistency(project *types.Project) error {
 			s.Deploy.Replicas = s.Scale
 		}
 
+		if s.CPUS != 0 && s.Deploy != nil {
+			if s.Deploy.Resources.Limits != nil && s.Deploy.Resources.Limits.NanoCPUs.Value() != s.CPUS {
+				return fmt.Errorf("services.%s: can't set distinct values on 'cpus' and 'deploy.resources.limits.cpus': %w",
+					s.Name, errdefs.ErrInvalid)
+			}
+		}
+		if s.MemLimit != 0 && s.Deploy != nil {
+			if s.Deploy.Resources.Limits != nil && s.Deploy.Resources.Limits.MemoryBytes != s.MemLimit {
+				return fmt.Errorf("services.%s: can't set distinct values on 'mem_limit' and 'deploy.resources.limits.memory': %w",
+					s.Name, errdefs.ErrInvalid)
+			}
+		}
+		if s.MemReservation != 0 && s.Deploy != nil {
+			if s.Deploy.Resources.Reservations != nil && s.Deploy.Resources.Reservations.MemoryBytes != s.MemReservation {
+				return fmt.Errorf("services.%s: can't set distinct values on 'mem_reservation' and 'deploy.resources.reservations.memory': %w",
+					s.Name, errdefs.ErrInvalid)
+			}
+		}
+		if s.PidsLimit != 0 && s.Deploy != nil {
+			if s.Deploy.Resources.Limits != nil && s.Deploy.Resources.Limits.Pids != s.PidsLimit {
+				return fmt.Errorf("services.%s: can't set distinct values on 'pids_limit' and 'deploy.resources.limits.pids': %w",
+					s.Name, errdefs.ErrInvalid)
+			}
+		}
+
 		if s.ContainerName != "" {
 			if existing, ok := containerNames[s.ContainerName]; ok {
 				return fmt.Errorf(`"services.%s": container name "%s" is already in use by "services.%s": %w`, s.Name, s.ContainerName, existing, errdefs.ErrInvalid)
diff --git a/template/template.go b/template/template.go
index 63ae61c..0d9c17e 100644
--- a/template/template.go
+++ b/template/template.go
@@ -44,7 +44,7 @@ var patternString = fmt.Sprintf(
 	groupInvalid,
 )
 
-var defaultPattern = regexp.MustCompile(patternString)
+var DefaultPattern = regexp.MustCompile(patternString)
 
 // InvalidTemplateError is returned when a variable template is not in a valid
 // format
@@ -121,7 +121,7 @@ func SubstituteWithOptions(template string, mapping Mapping, options ...Option)
 	var returnErr error
 
 	cfg := &Config{
-		pattern:         defaultPattern,
+		pattern:         DefaultPattern,
 		replacementFunc: DefaultReplacementFunc,
 		logging:         true,
 	}
@@ -268,14 +268,14 @@ func getFirstBraceClosingIndex(s string) int {
 
 // Substitute variables in the string with their values
 func Substitute(template string, mapping Mapping) (string, error) {
-	return SubstituteWith(template, mapping, defaultPattern)
+	return SubstituteWith(template, mapping, DefaultPattern)
 }
 
 // ExtractVariables returns a map of all the variables defined in the specified
 // composefile (dict representation) and their default value if any.
 func ExtractVariables(configDict map[string]interface{}, pattern *regexp.Regexp) map[string]Variable {
 	if pattern == nil {
-		pattern = defaultPattern
+		pattern = DefaultPattern
 	}
 	return recurseExtract(configDict, pattern)
 }
diff --git a/types/project.go b/types/project.go
index c12e966..b9d18eb 100644
--- a/types/project.go
+++ b/types/project.go
@@ -332,6 +332,9 @@ func (s ServiceConfig) HasProfile(profiles []string) bool {
 		return true
 	}
 	for _, p := range profiles {
+		if p == "*" {
+			return true
+		}
 		for _, sp := range s.Profiles {
 			if sp == p {
 				return true
@@ -345,11 +348,6 @@ func (s ServiceConfig) HasProfile(profiles []string) bool {
 // It returns a new Project instance with the changes and keep the original Project unchanged
 func (p *Project) WithProfiles(profiles []string) (*Project, error) {
 	newProject := p.deepCopy()
-	for _, p := range profiles {
-		if p == "*" {
-			return newProject, nil
-		}
-	}
 	enabled := Services{}
 	disabled := Services{}
 	for name, service := range newProject.AllServices() {
diff --git a/types/types.go b/types/types.go
index 8144180..2ee7f4c 100644
--- a/types/types.go
+++ b/types/types.go
@@ -20,6 +20,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"sort"
+	"strconv"
 	"strings"
 
 	"github.com/docker/go-connections/nat"
@@ -365,7 +366,7 @@ type Resources struct {
 // Resource is a resource to be limited or reserved
 type Resource struct {
 	// TODO: types to convert from units and ratios
-	NanoCPUs         string            `yaml:"cpus,omitempty" json:"cpus,omitempty"`
+	NanoCPUs         NanoCPUs          `yaml:"cpus,omitempty" json:"cpus,omitempty"`
 	MemoryBytes      UnitBytes         `yaml:"memory,omitempty" json:"memory,omitempty"`
 	Pids             int64             `yaml:"pids,omitempty" json:"pids,omitempty"`
 	Devices          []DeviceRequest   `yaml:"devices,omitempty" json:"devices,omitempty"`
@@ -374,6 +375,30 @@ type Resource struct {
 	Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
 }
 
+type NanoCPUs float32
+
+func (n *NanoCPUs) DecodeMapstructure(a any) error {
+	switch v := a.(type) {
+	case string:
+		f, err := strconv.ParseFloat(v, 64)
+		if err != nil {
+			return err
+		}
+		*n = NanoCPUs(f)
+	case float32:
+		*n = NanoCPUs(v)
+	case float64:
+		*n = NanoCPUs(v)
+	default:
+		return fmt.Errorf("unexpected value type %T for cpus", v)
+	}
+	return nil
+}
+
+func (n *NanoCPUs) Value() float32 {
+	return float32(*n)
+}
+
 // GenericResource represents a "user defined" resource which can
 // only be an integer (e.g: SSD=3) for a service
 type GenericResource struct {


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 57decff..a346b90 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -121,11 +121,11 @@ func services(workingDir, homeDir string) types.Services {
 				},
 				Resources: types.Resources{
 					Limits: &types.Resource{
-						NanoCPUs:    "0.001",
+						NanoCPUs:    0.001,
 						MemoryBytes: 50 * 1024 * 1024,
 					},
 					Reservations: &types.Resource{
-						NanoCPUs:    "0.0001",
+						NanoCPUs:    0.0001,
 						MemoryBytes: 20 * 1024 * 1024,
 						GenericResources: []types.GenericResource{
 							{
@@ -690,10 +690,10 @@ services:
         order: start-first
       resources:
         limits:
-          cpus: "0.001"
+          cpus: 0.001
           memory: "52428800"
         reservations:
-          cpus: "0.0001"
+          cpus: 0.0001
           memory: "20971520"
           generic_resources:
             - discrete_resource_spec:
@@ -1267,11 +1267,11 @@ func fullExampleJSON(workingDir, homeDir string) string {
         },
         "resources": {
           "limits": {
-            "cpus": "0.001",
+            "cpus": 0.001,
             "memory": "52428800"
           },
           "reservations": {
-            "cpus": "0.0001",
+            "cpus": 0.0001,
             "memory": "20971520",
             "generic_resources": [
               {
diff --git a/template/template_test.go b/template/template_test.go
index 66fa3c5..b1afa21 100644
--- a/template/template_test.go
+++ b/template/template_test.go
@@ -366,15 +366,15 @@ func TestSubstituteWithCustomFunc(t *testing.T) {
 		return value, true, nil
 	}
 
-	result, err := SubstituteWith("ok ${FOO}", defaultMapping, defaultPattern, errIsMissing)
+	result, err := SubstituteWith("ok ${FOO}", defaultMapping, DefaultPattern, errIsMissing)
 	assert.NilError(t, err)
 	assert.Check(t, is.Equal("ok first", result))
 
-	result, err = SubstituteWith("ok ${BAR}", defaultMapping, defaultPattern, errIsMissing)
+	result, err = SubstituteWith("ok ${BAR}", defaultMapping, DefaultPattern, errIsMissing)
 	assert.NilError(t, err)
 	assert.Check(t, is.Equal("ok ", result))
 
-	_, err = SubstituteWith("ok ${NOTHERE}", defaultMapping, defaultPattern, errIsMissing)
+	_, err = SubstituteWith("ok ${NOTHERE}", defaultMapping, DefaultPattern, errIsMissing)
 	assert.Check(t, is.ErrorContains(err, "required variable"))
 }
 
@@ -594,7 +594,7 @@ func TestExtractVariables(t *testing.T) {
 	for _, tc := range testCases {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
-			actual := ExtractVariables(tc.dict, defaultPattern)
+			actual := ExtractVariables(tc.dict, DefaultPattern)
 			assert.Check(t, is.DeepEqual(actual, tc.expected))
 		})
 	}