feat: Add support for compose devices mappings (#250)

* feat: Add support for compose `devices` mappings

https://docs.docker.com/reference/compose-file/services/#devices
```
services:
  foo:
    devices:
      - "/dev/ttyUSB0:/dev/ttyUSB0"
      - "/dev/sda:/dev/xvda:rwm"
```

* Lint fix
This commit is contained in:
Justin Bradford
2026-02-11 17:00:18 +00:00
committed by GitHub
parent 6e59657fe2
commit ab6f856987
9 changed files with 197 additions and 1 deletions
+2
View File
@@ -17,6 +17,8 @@ type ContainerResources struct {
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
// TODO: implement a placement constraint that checks available memory on machines.
MemoryReservation int64
// Device mappings for direct access to host devices
DeviceMappings []container.DeviceMapping
// Device reservations/requests for access to things like GPUs
DeviceReservations []container.DeviceRequest
// Ulimits defines the resource limits for the container.
+6
View File
@@ -383,6 +383,12 @@ func (s *ContainerSpec) Clone() ContainerSpec {
if s.Resources.Ulimits != nil {
spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits)
}
if s.Resources.DeviceMappings != nil {
spec.Resources.DeviceMappings = slices.Clone(s.Resources.DeviceMappings)
}
if s.Resources.DeviceReservations != nil {
spec.Resources.DeviceReservations = slices.Clone(s.Resources.DeviceReservations)
}
return spec
}
+13
View File
@@ -4,6 +4,7 @@ import (
"os"
"testing"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -231,6 +232,12 @@ func TestContainerSpec_Clone(t *testing.T) {
CPU: 1234,
Memory: 2345,
MemoryReservation: 3456,
DeviceMappings: []container.DeviceMapping{
{PathOnHost: "/dev/sda", PathInContainer: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{Count: 1, Capabilities: [][]string{{"gpu"}}, Driver: "nvidia"},
},
},
Sysctls: map[string]string{
"net.ipv4.ip_forward": "1",
@@ -263,6 +270,9 @@ func TestContainerSpec_Clone(t *testing.T) {
original.ConfigMounts[0].ContainerPath = stringModified
*original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value
original.Sysctls["net.ipv4.ip_forward"] = stringModified
original.Resources.DeviceMappings[0].PathOnHost = stringModified
original.Resources.DeviceReservations[0].Count = 2
original.Resources.DeviceReservations[0].Driver = stringModified
assert.False(t, original.Equals(cloned))
// Assert cloned values are unchanged
@@ -283,6 +293,9 @@ func TestContainerSpec_Clone(t *testing.T) {
assert.Equal(t, int64(1234), cloned.Resources.CPU)
assert.Equal(t, int64(2345), cloned.Resources.Memory)
assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation)
assert.Equal(t, "/dev/sda", cloned.Resources.DeviceMappings[0].PathOnHost)
assert.Equal(t, 1, cloned.Resources.DeviceReservations[0].Count)
assert.Equal(t, "nvidia", cloned.Resources.DeviceReservations[0].Driver)
assert.Equal(t, "1000:1000", cloned.User)
assert.Equal(t, "/data", cloned.Volumes[0])
assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath)
+23
View File
@@ -140,6 +140,7 @@ func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
Memory: int64(service.MemLimit),
MemoryReservation: int64(service.MemReservation),
Ulimits: ulimitsFromCompose(service.Ulimits),
DeviceMappings: devicesFromCompose(service.Devices),
}
// Convert GPU device requests from compose format, appending "gpu" capability.
@@ -335,6 +336,28 @@ func ulimitsFromCompose(ulimits map[string]*types.UlimitsConfig) map[string]api.
return res
}
func devicesFromCompose(composeDevices []types.DeviceMapping) []container.DeviceMapping {
mappings := make([]container.DeviceMapping, 0, len(composeDevices))
for _, dev := range composeDevices {
mapping := container.DeviceMapping{
PathOnHost: dev.Source,
PathInContainer: dev.Target,
CgroupPermissions: dev.Permissions,
}
if mapping.PathInContainer == "" {
mapping.PathInContainer = mapping.PathOnHost
}
if mapping.CgroupPermissions == "" {
mapping.CgroupPermissions = "rwm"
}
mappings = append(mappings, mapping)
}
return mappings
}
// validateServicesExtensions validates extension combinations across all services in the project.
func validateServicesExtensions(project *types.Project) error {
for _, service := range project.Services {
+85
View File
@@ -1050,3 +1050,88 @@ services:
})
}
}
func TestServiceSpecFromCompose_Devices(t *testing.T) {
tests := []struct {
name string
composeYAML string
expectedMappings []container.DeviceMapping
}{
{
name: "devices_simple",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
`,
expectedMappings: []container.DeviceMapping{
{
PathOnHost: "/dev/dri",
PathInContainer: "/dev/dri",
CgroupPermissions: "rwm",
},
},
},
{
name: "devices_full",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/sda:/dev/xvda:r
`,
expectedMappings: []container.DeviceMapping{
{
PathOnHost: "/dev/sda",
PathInContainer: "/dev/xvda",
CgroupPermissions: "r",
},
},
},
{
name: "multiple_devices",
composeYAML: `
services:
test:
image: nginx
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0:rw"
- /dev/sda:/dev/xvda
- "/dev/dri"
`,
expectedMappings: []container.DeviceMapping{
{
PathOnHost: "/dev/ttyUSB0",
PathInContainer: "/dev/ttyUSB0",
CgroupPermissions: "rw",
},
{
PathOnHost: "/dev/sda",
PathInContainer: "/dev/xvda",
CgroupPermissions: "rwm",
},
{
PathOnHost: "/dev/dri",
PathInContainer: "/dev/dri",
CgroupPermissions: "rwm",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
require.NoError(t, err)
assert.Equal(t, tt.expectedMappings, spec.Container.Resources.DeviceMappings,
"DeviceMappings should match expected")
})
}
}
+4 -1
View File
@@ -83,10 +83,13 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
}
}
// Device reservations are immutable, so we'll need to recreate if any have changed
// Device reservations and mappings are immutable, so we'll need to recreate if any have changed
if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) {
return ContainerNeedsRecreate
}
if !reflect.DeepEqual(current.Container.Resources.DeviceMappings, newResources.DeviceMappings) {
return ContainerNeedsRecreate
}
// Check if any mutable properties changed.
if !current.Caddy.Equals(new.Caddy) {
+62
View File
@@ -1410,6 +1410,68 @@ func TestEvalContainerSpecChange_Volumes(t *testing.T) {
}
}
func TestEvalContainerSpecChange_DeviceMappings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
current api.ContainerResources
new api.ContainerResources
want ContainerSpecStatus
}{
{
name: "empty",
current: api.ContainerResources{},
new: api.ContainerResources{},
want: ContainerUpToDate,
},
{
name: "identical mapping",
current: api.ContainerResources{DeviceMappings: []container.DeviceMapping{{PathOnHost: "/dev/foo", PathInContainer: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{DeviceMappings: []container.DeviceMapping{{PathOnHost: "/dev/foo", PathInContainer: "/dev/foo", CgroupPermissions: "rwm"}}},
want: ContainerUpToDate,
},
{
name: "add mapping",
current: api.ContainerResources{},
new: api.ContainerResources{DeviceMappings: []container.DeviceMapping{{PathOnHost: "/dev/foo", PathInContainer: "/dev/foo", CgroupPermissions: "rwm"}}},
want: ContainerNeedsRecreate,
},
{
name: "remove mapping",
current: api.ContainerResources{DeviceMappings: []container.DeviceMapping{{PathOnHost: "/dev/foo", PathInContainer: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{},
want: ContainerNeedsRecreate,
},
{
name: "change mapping path",
current: api.ContainerResources{DeviceMappings: []container.DeviceMapping{{PathOnHost: "/dev/foo", PathInContainer: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{DeviceMappings: []container.DeviceMapping{{PathOnHost: "/dev/foo", PathInContainer: "/dev/bar", CgroupPermissions: "rwm"}}},
want: ContainerNeedsRecreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.current,
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.new,
},
}
result := EvalContainerSpecChange(currentSpec, newSpec)
assert.Equal(t, tt.want, result)
})
}
}
func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) {
t.Parallel()