Repository: compose-spec/compose-go
Pull request #738 (2025-02-13 16:23:08)

Manage secrets|configs.mode as an octal string||number
A dedicated decoder is required for services.secrets.mode as octal value can be either yaml octal or a plain string

Closes https://github.com/moby/moby/issues/49404


Patch:
diff --git a/loader/interpolate.go b/loader/interpolate.go
index 481c66b..491de5b 100644
--- a/loader/interpolate.go
+++ b/loader/interpolate.go
@@ -27,7 +27,6 @@ import (
 )
 
 var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
-	servicePath("configs", tree.PathMatchList, "mode"):             toInt,
 	servicePath("cpu_count"):                                       toInt64,
 	servicePath("cpu_percent"):                                     toFloat,
 	servicePath("cpu_period"):                                      toInt64,
@@ -53,7 +52,6 @@ var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
 	servicePath("privileged"):                                      toBoolean,
 	servicePath("read_only"):                                       toBoolean,
 	servicePath("scale"):                                           toInt,
-	servicePath("secrets", tree.PathMatchList, "mode"):             toInt,
 	servicePath("stdin_open"):                                      toBoolean,
 	servicePath("tty"):                                             toBoolean,
 	servicePath("ulimits", tree.PathMatchAll):                      toInt,
diff --git a/types/derived.gen.go b/types/derived.gen.go
index 17b22e6..fd8e059 100644
--- a/types/derived.gen.go
+++ b/types/derived.gen.go
@@ -1605,7 +1605,7 @@ func deriveDeepCopy_31(dst, src *ServiceConfigObjConfig) {
 	if src.Mode == nil {
 		dst.Mode = nil
 	} else {
-		dst.Mode = new(uint32)
+		dst.Mode = new(FileMode)
 		*dst.Mode = *src.Mode
 	}
 	if src.Extensions != nil {
@@ -1892,7 +1892,7 @@ func deriveDeepCopy_41(dst, src *ServiceSecretConfig) {
 	if src.Mode == nil {
 		dst.Mode = nil
 	} else {
-		dst.Mode = new(uint32)
+		dst.Mode = new(FileMode)
 		*dst.Mode = *src.Mode
 	}
 	if src.Extensions != nil {
diff --git a/types/types.go b/types/types.go
index 1773f69..fe95444 100644
--- a/types/types.go
+++ b/types/types.go
@@ -20,6 +20,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"sort"
+	"strconv"
 	"strings"
 	"time"
 
@@ -626,17 +627,51 @@ type ServiceVolumeTmpfs struct {
 	Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
 }
 
+type FileMode int64
+
 // FileReferenceConfig for a reference to a swarm file object
 type FileReferenceConfig struct {
-	Source string  `yaml:"source,omitempty" json:"source,omitempty"`
-	Target string  `yaml:"target,omitempty" json:"target,omitempty"`
-	UID    string  `yaml:"uid,omitempty" json:"uid,omitempty"`
-	GID    string  `yaml:"gid,omitempty" json:"gid,omitempty"`
-	Mode   *uint32 `yaml:"mode,omitempty" json:"mode,omitempty"`
+	Source string    `yaml:"source,omitempty" json:"source,omitempty"`
+	Target string    `yaml:"target,omitempty" json:"target,omitempty"`
+	UID    string    `yaml:"uid,omitempty" json:"uid,omitempty"`
+	GID    string    `yaml:"gid,omitempty" json:"gid,omitempty"`
+	Mode   *FileMode `yaml:"mode,omitempty" json:"mode,omitempty"`
 
 	Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
 }
 
+func (f *FileMode) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case *FileMode:
+		return nil
+	case string:
+		i, err := strconv.ParseInt(v, 8, 64)
+		if err != nil {
+			return err
+		}
+		*f = FileMode(i)
+	case int:
+		*f = FileMode(v)
+	default:
+		return fmt.Errorf("unexpected value type %T for mode", value)
+	}
+	return nil
+}
+
+// MarshalYAML makes FileMode implement yaml.Marshaller
+func (f *FileMode) MarshalYAML() (interface{}, error) {
+	return f.String(), nil
+}
+
+// MarshalJSON makes FileMode implement json.Marshaller
+func (f *FileMode) MarshalJSON() ([]byte, error) {
+	return []byte("\"" + f.String() + "\""), nil
+}
+
+func (f *FileMode) String() string {
+	return fmt.Sprintf("0%o", int64(*f))
+}
+
 // ServiceConfigObjConfig is the config obj configuration for a service
 type ServiceConfigObjConfig FileReferenceConfig
 


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 8c50ef7..07e258d 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -72,7 +72,7 @@ func services(workingDir, homeDir string) types.Services {
 						Target: "my_secret",
 						UID:    "103",
 						GID:    "103",
-						Mode:   uint32Ptr(0o440),
+						Mode:   ptr(types.FileMode(0o440)),
 					},
 				},
 				Tags:      []string{"foo:v1.0.0", "docker.io/username/foo:my-other-tag", "full_example_project_name:1.0.0"},
@@ -91,7 +91,7 @@ func services(workingDir, homeDir string) types.Services {
 					Target: "/my_config",
 					UID:    "103",
 					GID:    "103",
-					Mode:   uint32Ptr(0o440),
+					Mode:   ptr(types.FileMode(0o440)),
 				},
 			},
 			ContainerName: "my-web-container",
@@ -101,10 +101,10 @@ func services(workingDir, homeDir string) types.Services {
 			},
 			Deploy: &types.DeployConfig{
 				Mode:     "replicated",
-				Replicas: intPtr(6),
+				Replicas: ptr(6),
 				Labels:   map[string]string{"FOO": "BAR"},
 				RollbackConfig: &types.UpdateConfig{
-					Parallelism:     uint64Ptr(3),
+					Parallelism:     ptr(uint64(3)),
 					Delay:           types.Duration(10 * time.Second),
 					FailureAction:   "continue",
 					Monitor:         types.Duration(60 * time.Second),
@@ -112,7 +112,7 @@ func services(workingDir, homeDir string) types.Services {
 					Order:           "start-first",
 				},
 				UpdateConfig: &types.UpdateConfig{
-					Parallelism:     uint64Ptr(3),
+					Parallelism:     ptr(uint64(3)),
 					Delay:           types.Duration(10 * time.Second),
 					FailureAction:   "continue",
 					Monitor:         types.Duration(60 * time.Second),
@@ -145,9 +145,9 @@ func services(workingDir, homeDir string) types.Services {
 				},
 				RestartPolicy: &types.RestartPolicy{
 					Condition:   types.RestartPolicyOnFailure,
-					Delay:       durationPtr(5 * time.Second),
-					MaxAttempts: uint64Ptr(3),
-					Window:      durationPtr(2 * time.Minute),
+					Delay:       ptr(types.Duration(5 * time.Second)),
+					MaxAttempts: ptr(uint64(3)),
+					Window:      ptr(types.Duration(2 * time.Minute)),
 				},
 				Placement: types.Placement{
 					Constraints: []string{"node=foo"},
@@ -207,11 +207,11 @@ func services(workingDir, homeDir string) types.Services {
 			},
 			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),
-				StartInterval: durationPtr(5 * time.Second),
+				Interval:      ptr(types.Duration(10 * time.Second)),
+				Timeout:       ptr(types.Duration(1 * time.Second)),
+				Retries:       ptr(uint64(5)),
+				StartPeriod:   ptr(types.Duration(15 * time.Second)),
+				StartInterval: ptr(types.Duration(5 * time.Second)),
 			},
 			Hostname: "foo",
 			Image:    "redis",
@@ -418,7 +418,7 @@ func services(workingDir, homeDir string) types.Services {
 					Target: "my_secret",
 					UID:    "103",
 					GID:    "103",
-					Mode:   uint32Ptr(0o440),
+					Mode:   ptr(types.FileMode(0o440)),
 				},
 			},
 			SecurityOpt: []string{
@@ -428,7 +428,7 @@ func services(workingDir, homeDir string) types.Services {
 			StdinOpen:       true,
 			StopSignal:      "SIGUSR1",
 			StorageOpt:      map[string]string{"size": "20G"},
-			StopGracePeriod: durationPtr(20 * time.Second),
+			StopGracePeriod: ptr(types.Duration(20 * time.Second)),
 			Sysctls: map[string]string{
 				"net.core.somaxconn":      "1024",
 				"net.ipv4.tcp_syncookies": "0",
@@ -649,7 +649,7 @@ services:
           target: my_secret
           uid: "103"
           gid: "103"
-          mode: 288
+          mode: "0440"
       tags:
         - foo:v1.0.0
         - docker.io/username/foo:my-other-tag
@@ -675,7 +675,7 @@ services:
         target: /my_config
         uid: "103"
         gid: "103"
-        mode: 288
+        mode: "0440"
     container_name: my-web-container
     depends_on:
       db:
@@ -919,7 +919,7 @@ services:
         target: my_secret
         uid: "103"
         gid: "103"
-        mode: 288
+        mode: "0440"
     security_opt:
       - label=level:s0:c100,c200
       - label=type:svirt_apache_t
@@ -1220,7 +1220,7 @@ func fullExampleJSON(workingDir, homeDir string) string {
             "target": "my_secret",
             "uid": "103",
             "gid": "103",
-            "mode": 288
+            "mode": "0440"
           }
         ],
         "tags": [
@@ -1257,7 +1257,7 @@ func fullExampleJSON(workingDir, homeDir string) string {
           "target": "/my_config",
           "uid": "103",
           "gid": "103",
-          "mode": 288
+          "mode": "0440"
         }
       ],
       "container_name": "my-web-container",
@@ -1599,7 +1599,7 @@ func fullExampleJSON(workingDir, homeDir string) string {
           "target": "my_secret",
           "uid": "103",
           "gid": "103",
-          "mode": 288
+          "mode": "0440"
         }
       ],
       "security_opt": [
diff --git a/loader/loader_test.go b/loader/loader_test.go
index b510aa8..e2814f7 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -699,10 +699,10 @@ services:
   web:
     configs:
       - source: appconfig
-        mode: $theint
+        mode: "$theint"
     secrets:
       - source: super
-        mode: $theint
+        mode: "$theint"
     healthcheck:
       retries: ${theint}
       disable: $thebool
@@ -789,32 +789,32 @@ networks:
 				Configs: []types.ServiceConfigObjConfig{
 					{
 						Source: "appconfig",
-						Mode:   uint32Ptr(555),
+						Mode:   ptr(types.FileMode(0o555)),
 					},
 				},
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "super",
 						Target: "/run/secrets/super",
-						Mode:   uint32Ptr(555),
+						Mode:   ptr(types.FileMode(0o555)),
 					},
 				},
 				HealthCheck: &types.HealthCheckConfig{
-					Retries: uint64Ptr(555),
+					Retries: ptr(uint64(555)),
 					Disable: true,
 				},
 				Deploy: &types.DeployConfig{
-					Replicas: intPtr(555),
+					Replicas: ptr(555),
 					UpdateConfig: &types.UpdateConfig{
-						Parallelism:     uint64Ptr(555),
+						Parallelism:     ptr(uint64(555)),
 						MaxFailureRatio: 3.14,
 					},
 					RollbackConfig: &types.UpdateConfig{
-						Parallelism:     uint64Ptr(555),
+						Parallelism:     ptr(uint64(555)),
 						MaxFailureRatio: 3.14,
 					},
 					RestartPolicy: &types.RestartPolicy{
-						MaxAttempts: uint64Ptr(555),
+						MaxAttempts: ptr(uint64(555)),
 					},
 					Placement: types.Placement{
 						MaxReplicas: 555,
@@ -1122,21 +1122,8 @@ services:
 	assert.Equal(t, *foo.Scale, 2)
 }
 
-func durationPtr(value time.Duration) *types.Duration {
-	result := types.Duration(value)
-	return &result
-}
-
-func intPtr(value int) *int {
-	return &value
-}
-
-func uint64Ptr(value uint64) *uint64 {
-	return &value
-}
-
-func uint32Ptr(value uint32) *uint32 {
-	return &value
+func ptr[T any](t T) *T {
+	return &t
 }
 
 func TestFullExample(t *testing.T) {
@@ -3744,3 +3731,33 @@ services:
 `)
 	assert.Check(t, strings.Contains(err.Error(), "'services[test].environment': environment variable DEBUG  is declared with a trailing space"))
 }
+
+func TestFileModeNumber(t *testing.T) {
+	p, err := loadYAML(`
+name: load-file-mode
+services:
+  test:
+    secrets:
+      - source: server-certificate
+        target: server.cert
+        mode: 0o440 
+`)
+	assert.NilError(t, err)
+	assert.Equal(t, len(p.Services["test"].Secrets), 1)
+	assert.Equal(t, *p.Services["test"].Secrets[0].Mode, types.FileMode(0o440))
+}
+
+func TestFileModeString(t *testing.T) {
+	p, err := loadYAML(`
+name: load-file-mode
+services:
+  test:
+    secrets:
+      - source: server-certificate
+        target: server.cert
+        mode: "0440" 
+`)
+	assert.NilError(t, err)
+	assert.Equal(t, len(p.Services["test"].Secrets), 1)
+	assert.Equal(t, *p.Services["test"].Secrets[0].Mode, types.FileMode(0o440))
+}