From 9bae98b5b3c1f13bb9161cc18b3dcbda8071dc4f Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Sun, 23 Aug 2026 14:47:04 +0500 Subject: [PATCH] cli/compose: accept numeric cpus in stack deploy schema compose config emits deploy.resources.cpus as a number. The v3 schema only allowed strings, so piping one into docker stack deploy failed with "cpus must be a string". Signed-off-by: Dean Chen <862469039@qq.com> --- cli/compose/loader/loader.go | 25 ++++++++++++++- cli/compose/loader/loader_test.go | 31 +++++++++++++++++++ .../schema/data/config_schema_v3.0.json | 2 +- .../schema/data/config_schema_v3.1.json | 2 +- .../schema/data/config_schema_v3.10.json | 4 +-- .../schema/data/config_schema_v3.11.json | 4 +-- .../schema/data/config_schema_v3.12.json | 4 +-- .../schema/data/config_schema_v3.13.json | 4 +-- .../schema/data/config_schema_v3.2.json | 2 +- .../schema/data/config_schema_v3.3.json | 2 +- .../schema/data/config_schema_v3.4.json | 2 +- .../schema/data/config_schema_v3.5.json | 4 +-- .../schema/data/config_schema_v3.6.json | 4 +-- .../schema/data/config_schema_v3.7.json | 4 +-- .../schema/data/config_schema_v3.8.json | 4 +-- .../schema/data/config_schema_v3.9.json | 4 +-- cli/compose/schema/schema_test.go | 23 ++++++++++++++ 17 files changed, 101 insertions(+), 24 deletions(-) diff --git a/cli/compose/loader/loader.go b/cli/compose/loader/loader.go index 0ddebce78543..288612da30dd 100644 --- a/cli/compose/loader/loader.go +++ b/cli/compose/loader/loader.go @@ -299,7 +299,8 @@ func Transform(source any, target any, additionalTransformers ...Transformer) er config := &mapstructure.DecoderConfig{ DecodeHook: mapstructure.ComposeDecodeHookFunc( createTransformHook(additionalTransformers...), - mapstructure.StringToTimeDurationHookFunc()), + mapstructure.StringToTimeDurationHookFunc(), + numberToStringHook()), Result: target, Metadata: &data, } @@ -356,6 +357,28 @@ func createTransformHook(additionalTransformers ...Transformer) mapstructure.Dec } } +// numberToStringHook lets numeric YAML/JSON values land in string fields +// (notably deploy.resources.*.cpus, which compose config emits as numbers). +func numberToStringHook() mapstructure.DecodeHookFunc { + return func(_ reflect.Type, to reflect.Type, data any) (any, error) { + if to.Kind() != reflect.String { + return data, nil + } + switch v := data.(type) { + case string: + return v, nil + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return fmt.Sprint(v), nil + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32), nil + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), nil + default: + return data, nil + } + } +} + // keys needs to be converted to strings for jsonschema func convertToStringKeysRecursive(value any, keyPrefix string) (any, error) { if mapping, ok := value.(map[string]any); ok { diff --git a/cli/compose/loader/loader_test.go b/cli/compose/loader/loader_test.go index 8df5d150934d..e73dd20da3f9 100644 --- a/cli/compose/loader/loader_test.go +++ b/cli/compose/loader/loader_test.go @@ -902,6 +902,37 @@ func TestInvalidResource(t *testing.T) { assert.Check(t, is.ErrorContains(err, "Additional property impossible is not allowed")) } +func TestLoadNumericCPUs(t *testing.T) { + config, err := loadYAML(` +version: "3.13" +services: + foo: + image: busybox + deploy: + resources: + limits: + cpus: 0.5 + reservations: + cpus: 1 +`) + assert.NilError(t, err) + assert.Check(t, is.Equal("0.5", config.Services[0].Deploy.Resources.Limits.NanoCPUs)) + assert.Check(t, is.Equal("1", config.Services[0].Deploy.Resources.Reservations.NanoCPUs)) + + config, err = loadYAML(` +version: "3.13" +services: + foo: + image: busybox + deploy: + resources: + limits: + cpus: "0.25" +`) + assert.NilError(t, err) + assert.Check(t, is.Equal("0.25", config.Services[0].Deploy.Resources.Limits.NanoCPUs)) +} + func TestInvalidExternalAndDriverCombination(t *testing.T) { _, err := loadYAML(` version: "3" diff --git a/cli/compose/schema/data/config_schema_v3.0.json b/cli/compose/schema/data/config_schema_v3.0.json index f39344cfbe74..1ccf30e72788 100644 --- a/cli/compose/schema/data/config_schema_v3.0.json +++ b/cli/compose/schema/data/config_schema_v3.0.json @@ -268,7 +268,7 @@ "id": "#/definitions/resource", "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false diff --git a/cli/compose/schema/data/config_schema_v3.1.json b/cli/compose/schema/data/config_schema_v3.1.json index 719c0fa7acc5..e470cc4717ea 100644 --- a/cli/compose/schema/data/config_schema_v3.1.json +++ b/cli/compose/schema/data/config_schema_v3.1.json @@ -297,7 +297,7 @@ "id": "#/definitions/resource", "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false diff --git a/cli/compose/schema/data/config_schema_v3.10.json b/cli/compose/schema/data/config_schema_v3.10.json index 7c032cf54b31..0fa5f2f13ee2 100644 --- a/cli/compose/schema/data/config_schema_v3.10.json +++ b/cli/compose/schema/data/config_schema_v3.10.json @@ -391,7 +391,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "pids": {"type": "integer"} }, @@ -400,7 +400,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.11.json b/cli/compose/schema/data/config_schema_v3.11.json index fb2c9fd84b0c..c60fc3662830 100644 --- a/cli/compose/schema/data/config_schema_v3.11.json +++ b/cli/compose/schema/data/config_schema_v3.11.json @@ -391,7 +391,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "pids": {"type": "integer"} }, @@ -400,7 +400,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.12.json b/cli/compose/schema/data/config_schema_v3.12.json index 2a548a38163d..ccbeda59db8f 100644 --- a/cli/compose/schema/data/config_schema_v3.12.json +++ b/cli/compose/schema/data/config_schema_v3.12.json @@ -392,7 +392,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "pids": {"type": "integer"} }, @@ -401,7 +401,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.13.json b/cli/compose/schema/data/config_schema_v3.13.json index 8daa8892d625..e336075de95f 100644 --- a/cli/compose/schema/data/config_schema_v3.13.json +++ b/cli/compose/schema/data/config_schema_v3.13.json @@ -399,7 +399,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "pids": {"type": "integer"} }, @@ -408,7 +408,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.2.json b/cli/compose/schema/data/config_schema_v3.2.json index 6e0e0e747da9..7599721d5a12 100644 --- a/cli/compose/schema/data/config_schema_v3.2.json +++ b/cli/compose/schema/data/config_schema_v3.2.json @@ -343,7 +343,7 @@ "id": "#/definitions/resource", "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false diff --git a/cli/compose/schema/data/config_schema_v3.3.json b/cli/compose/schema/data/config_schema_v3.3.json index 13a58044d843..e54bbacece95 100644 --- a/cli/compose/schema/data/config_schema_v3.3.json +++ b/cli/compose/schema/data/config_schema_v3.3.json @@ -391,7 +391,7 @@ "id": "#/definitions/resource", "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false diff --git a/cli/compose/schema/data/config_schema_v3.4.json b/cli/compose/schema/data/config_schema_v3.4.json index 8660c98da42b..fce45f87ef15 100644 --- a/cli/compose/schema/data/config_schema_v3.4.json +++ b/cli/compose/schema/data/config_schema_v3.4.json @@ -398,7 +398,7 @@ "id": "#/definitions/resource", "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false diff --git a/cli/compose/schema/data/config_schema_v3.5.json b/cli/compose/schema/data/config_schema_v3.5.json index bf9c56c02e37..57064dd11fa9 100644 --- a/cli/compose/schema/data/config_schema_v3.5.json +++ b/cli/compose/schema/data/config_schema_v3.5.json @@ -363,7 +363,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false @@ -371,7 +371,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.6.json b/cli/compose/schema/data/config_schema_v3.6.json index cd6a638ceb7f..af277910a4d7 100644 --- a/cli/compose/schema/data/config_schema_v3.6.json +++ b/cli/compose/schema/data/config_schema_v3.6.json @@ -372,7 +372,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false @@ -380,7 +380,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.7.json b/cli/compose/schema/data/config_schema_v3.7.json index 69d5c52f87f5..66f0d36ee4f6 100644 --- a/cli/compose/schema/data/config_schema_v3.7.json +++ b/cli/compose/schema/data/config_schema_v3.7.json @@ -388,7 +388,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false @@ -396,7 +396,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.8.json b/cli/compose/schema/data/config_schema_v3.8.json index 059c0bcf76d8..e22bced41d11 100644 --- a/cli/compose/schema/data/config_schema_v3.8.json +++ b/cli/compose/schema/data/config_schema_v3.8.json @@ -389,7 +389,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false @@ -397,7 +397,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/data/config_schema_v3.9.json b/cli/compose/schema/data/config_schema_v3.9.json index c6f63fda46d3..04dea096b41e 100644 --- a/cli/compose/schema/data/config_schema_v3.9.json +++ b/cli/compose/schema/data/config_schema_v3.9.json @@ -391,7 +391,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "pids": {"type": "integer"} }, @@ -400,7 +400,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, diff --git a/cli/compose/schema/schema_test.go b/cli/compose/schema/schema_test.go index a5d5e45ad20e..13a3e1ff94d6 100644 --- a/cli/compose/schema/schema_test.go +++ b/cli/compose/schema/schema_test.go @@ -135,6 +135,29 @@ func TestValidatePorts(t *testing.T) { } } +func TestValidateNumericCPUs(t *testing.T) { + config := dict{ + "services": dict{ + "foo": dict{ + "image": "busybox", + "deploy": dict{ + "resources": dict{ + "limits": dict{ + "cpus": 0.5, + }, + "reservations": dict{ + "cpus": 1, + }, + }, + }, + }, + }, + } + + assert.NilError(t, Validate(config, "3.13")) + assert.NilError(t, Validate(config, "3")) +} + func TestValidateUndefinedTopLevelOption(t *testing.T) { config := dict{ "version": "3.0",