Repository: compose-spec/compose-go
Pull request #676 (2024-08-19 09:20:38)

long syntax for device mapping
introduce long syntax for device mapping, to help disambiguate path with colons

closes https://github.com/docker/compose/issues/12052

ndeloof: cc @idsulik 

Patch:
diff --git a/loader/full-example.yml b/loader/full-example.yml
index 25dfbce..2f7b8c4 100644
--- a/loader/full-example.yml
+++ b/loader/full-example.yml
@@ -118,7 +118,9 @@ services:
       - "a 7:* rmw"
 
     devices:
-      - "/dev/ttyUSB0:/dev/ttyUSB0"
+      - source: /dev/ttyUSB0
+        target: /dev/ttyUSB0
+        permissions: rwm
 
     # String or list
     # dns: 8.8.8.8
diff --git a/override/uncity.go b/override/uncity.go
index 620a70a..9d1b895 100644
--- a/override/uncity.go
+++ b/override/uncity.go
@@ -62,6 +62,7 @@ func init() {
 	unique["services.*.sysctls"] = keyValueIndexer
 	unique["services.*.tmpfs"] = keyValueIndexer
 	unique["services.*.volumes"] = volumeIndexer
+	unique["services.*.devices"] = deviceMappingIndexer
 }
 
 // EnforceUnicity removes redefinition of elements declared in a sequence
@@ -139,6 +140,24 @@ func volumeIndexer(y any, p tree.Path) (string, error) {
 	return "", nil
 }
 
+func deviceMappingIndexer(y any, p tree.Path) (string, error) {
+	switch value := y.(type) {
+	case map[string]any:
+		target, ok := value["target"].(string)
+		if !ok {
+			return "", fmt.Errorf("service device %s is missing a mount target", p)
+		}
+		return target, nil
+	case string:
+		arr := strings.Split(value, ":")
+		if len(arr) == 1 {
+			return arr[0], nil
+		}
+		return arr[1], nil
+	}
+	return "", nil
+}
+
 func exposeIndexer(a any, path tree.Path) (string, error) {
 	switch v := a.(type) {
 	case string:
diff --git a/schema/compose-spec.json b/schema/compose-spec.json
index 335cbe0..cb4f89e 100644
--- a/schema/compose-spec.json
+++ b/schema/compose-spec.json
@@ -216,7 +216,25 @@
           ]
         },
         "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"},
-        "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "devices": {
+          "type": "array",
+          "items": {
+            "oneOf": [
+              {"type": "string"},
+              {
+                "type": "object",
+                "required": ["source"],
+                "properties": {
+                  "source": {"type": "string"},
+                  "target": {"type": "string"},
+                  "permissions": {"type": "string"}
+                },
+                "additionalProperties": false,
+                "patternProperties": {"^x-": {}}
+              }
+            ]
+          }
+        },
         "dns": {"$ref": "#/definitions/string_or_list"},
         "dns_opt": {"type": "array","items": {"type": "string"}, "uniqueItems": true},
         "dns_search": {"$ref": "#/definitions/string_or_list"},
diff --git a/transform/canonical.go b/transform/canonical.go
index 1240c77..a248b4b 100644
--- a/transform/canonical.go
+++ b/transform/canonical.go
@@ -33,6 +33,7 @@ func init() {
 	transformers["services.*.extends"] = transformExtends
 	transformers["services.*.networks"] = transformServiceNetworks
 	transformers["services.*.volumes.*"] = transformVolumeMount
+	transformers["services.*.devices.*"] = transformDeviceMapping
 	transformers["services.*.secrets.*"] = transformFileMount
 	transformers["services.*.configs.*"] = transformFileMount
 	transformers["services.*.ports"] = transformPorts
diff --git a/transform/device.go b/transform/device.go
new file mode 100644
index 0000000..351d815
--- /dev/null
+++ b/transform/device.go
@@ -0,0 +1,60 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package transform
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/compose-spec/compose-go/v2/tree"
+)
+
+func transformDeviceMapping(data any, p tree.Path, ignoreParseError bool) (any, error) {
+	switch v := data.(type) {
+	case map[string]any:
+		return v, nil
+	case string:
+		src := ""
+		dst := ""
+		permissions := "rwm"
+		arr := strings.Split(v, ":")
+		switch len(arr) {
+		case 3:
+			permissions = arr[2]
+			fallthrough
+		case 2:
+			dst = arr[1]
+			fallthrough
+		case 1:
+			src = arr[0]
+		default:
+			if !ignoreParseError {
+				return nil, fmt.Errorf("confusing device mapping, please use long syntax: %s", v)
+			}
+		}
+		if dst == "" {
+			dst = src
+		}
+		return map[string]any{
+			"source":      src,
+			"target":      dst,
+			"permissions": permissions,
+		}, nil
+	default:
+		return data, fmt.Errorf("%s: invalid type %T for service volume mount", p, v)
+	}
+}
diff --git a/types/derived.gen.go b/types/derived.gen.go
index c78b17c..d5d1d02 100644
--- a/types/derived.gen.go
+++ b/types/derived.gen.go
@@ -271,13 +271,13 @@ func deriveDeepCopyService(dst, src *ServiceConfig) {
 				if cap(dst.Devices) >= len(src.Devices) {
 					dst.Devices = (dst.Devices)[:len(src.Devices)]
 				} else {
-					dst.Devices = make([]string, len(src.Devices))
+					dst.Devices = make([]DeviceMapping, len(src.Devices))
 				}
 			} else if len(src.Devices) < len(dst.Devices) {
 				dst.Devices = (dst.Devices)[:len(src.Devices)]
 			}
 		} else {
-			dst.Devices = make([]string, len(src.Devices))
+			dst.Devices = make([]DeviceMapping, len(src.Devices))
 		}
 		copy(dst.Devices, src.Devices)
 	}
diff --git a/types/types.go b/types/types.go
index b0f97bf..764e743 100644
--- a/types/types.go
+++ b/types/types.go
@@ -62,7 +62,7 @@ type ServiceConfig struct {
 	DependsOn         DependsOnConfig          `yaml:"depends_on,omitempty" json:"depends_on,omitempty"`
 	Deploy            *DeployConfig            `yaml:"deploy,omitempty" json:"deploy,omitempty"`
 	DeviceCgroupRules []string                 `yaml:"device_cgroup_rules,omitempty" json:"device_cgroup_rules,omitempty"`
-	Devices           []string                 `yaml:"devices,omitempty" json:"devices,omitempty"`
+	Devices           []DeviceMapping          `yaml:"devices,omitempty" json:"devices,omitempty"`
 	DNS               StringList               `yaml:"dns,omitempty" json:"dns,omitempty"`
 	DNSOpts           []string                 `yaml:"dns_opt,omitempty" json:"dns_opt,omitempty"`
 	DNSSearch         StringList               `yaml:"dns_search,omitempty" json:"dns_search,omitempty"`
@@ -301,6 +301,14 @@ type BlkioConfig struct {
 	Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
 }
 
+type DeviceMapping struct {
+	Source      string `yaml:"source,omitempty" json:"source,omitempty"`
+	Target      string `yaml:"target,omitempty" json:"target,omitempty"`
+	Permissions string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
+
+	Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
+}
+
 // WeightDevice is a structure that holds device:weight pair
 type WeightDevice struct {
 	Path   string


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 9d12274..7e8ac9e 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -164,7 +164,10 @@ func services(workingDir, homeDir string) types.Services {
 				"c 1:3 mr",
 				"a 7:* rmw",
 			},
-			Devices:    []string{"/dev/ttyUSB0:/dev/ttyUSB0"},
+			Devices: []types.DeviceMapping{
+				{
+					Source: "/dev/ttyUSB0", Target: "/dev/ttyUSB0", Permissions: "rwm",
+				}},
 			DNS:        []string{"8.8.8.8", "9.9.9.9"},
 			DNSSearch:  []string{"dc1.example.com", "dc2.example.com"},
 			DomainName: "foo.com",
@@ -719,7 +722,9 @@ services:
       - c 1:3 mr
       - a 7:* rmw
     devices:
-      - /dev/ttyUSB0:/dev/ttyUSB0
+      - source: /dev/ttyUSB0
+        target: /dev/ttyUSB0
+        permissions: rwm
     dns:
       - 8.8.8.8
       - 9.9.9.9
@@ -1314,7 +1319,11 @@ func fullExampleJSON(workingDir, homeDir string) string {
         "a 7:* rmw"
       ],
       "devices": [
-        "/dev/ttyUSB0:/dev/ttyUSB0"
+        {
+          "source": "/dev/ttyUSB0",
+          "target": "/dev/ttyUSB0",
+          "permissions": "rwm"
+        }
       ],
       "dns": [
         "8.8.8.8",
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 2cb1d49..01ae3eb 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -3329,3 +3329,51 @@ secrets:
 			Content:     "Shadoks",
 		}})
 }
+
+func TestLoadDeviceMapping(t *testing.T) {
+	config, err := loadYAML(`
+name: load-device-mapping
+services:
+  test:
+    devices:
+      - /dev/source:/dev/target:permissions
+      - /dev/single
+`)
+	assert.NilError(t, err)
+	assert.DeepEqual(t, config.Services["test"].Devices, []types.DeviceMapping{
+		{
+			Source:      "/dev/source",
+			Target:      "/dev/target",
+			Permissions: "permissions",
+		},
+		{
+			Source:      "/dev/single",
+			Target:      "/dev/single",
+			Permissions: "rwm",
+		},
+	})
+}
+
+func TestLoadDeviceMappingLongSyntax(t *testing.T) {
+	config, err := loadYAML(`
+name: load-device-mapping-long-syntax
+services:
+  test:
+    devices:
+      - source: /dev/source
+        target: /dev/target
+        permissions: permissions
+        x-foo: bar
+`)
+	assert.NilError(t, err)
+	assert.DeepEqual(t, config.Services["test"].Devices, []types.DeviceMapping{
+		{
+			Source:      "/dev/source",
+			Target:      "/dev/target",
+			Permissions: "permissions",
+			Extensions: map[string]any{
+				"x-foo": "bar",
+			},
+		},
+	})
+}