diff --git a/loader/full-example.yml b/loader/full-example.yml index 25dfbce6..2f7b8c43 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/loader/full-struct_test.go b/loader/full-struct_test.go index 9d12274e..7e8ac9e4 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 2cb1d49c..01ae3ebe 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", + }, + }, + }) +} diff --git a/override/uncity.go b/override/uncity.go index 620a70a2..9d1b895a 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 335cbe09..cb4f89e0 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 1240c77c..a248b4be 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 00000000..351d8151 --- /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 c78b17c8..d5d1d024 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 b0f97bf9..764e743b 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