diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index 72948761..9b44f5af 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -617,6 +617,7 @@ func (s *Server) CreateServiceContainer( NanoCPUs: spec.Container.Resources.CPU, Memory: spec.Container.Resources.Memory, MemoryReservation: spec.Container.Resources.MemoryReservation, + Devices: spec.Container.Resources.DeviceMappings, DeviceRequests: spec.Container.Resources.DeviceReservations, Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits), }, diff --git a/pkg/api/resources.go b/pkg/api/resources.go index 3efe1ce8..f077a635 100644 --- a/pkg/api/resources.go +++ b/pkg/api/resources.go @@ -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. diff --git a/pkg/api/service.go b/pkg/api/service.go index de800ef0..bcd544c8 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -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 } diff --git a/pkg/api/service_test.go b/pkg/api/service_test.go index 3654780d..8a4f2144 100644 --- a/pkg/api/service_test.go +++ b/pkg/api/service_test.go @@ -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) diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index e5bd94c1..d1983c11 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -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 { diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index b2390fc5..02e1fb40 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -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") + }) + } +} diff --git a/pkg/client/deploy/container.go b/pkg/client/deploy/container.go index 361740d1..eb1b72ac 100644 --- a/pkg/client/deploy/container.go +++ b/pkg/client/deploy/container.go @@ -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) { diff --git a/pkg/client/deploy/container_test.go b/pkg/client/deploy/container_test.go index 355d63bc..1ba53340 100644 --- a/pkg/client/deploy/container_test.go +++ b/pkg/client/deploy/container_test.go @@ -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() diff --git a/website/docs/8-compose-file-reference/1-support-matrix.md b/website/docs/8-compose-file-reference/1-support-matrix.md index e322f9cd..98334044 100644 --- a/website/docs/8-compose-file-reference/1-support-matrix.md +++ b/website/docs/8-compose-file-reference/1-support-matrix.md @@ -13,6 +13,7 @@ The following table shows the support status for main Compose features: | `configs` | ✅ Supported | File-based and inline configs | | `cpus` | ✅ Supported | CPU limit | | `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked | +| `devices` | ✅ Supported | Device mappings | | `dns` | ❌ Not supported | Built-in service discovery | | `dns_search` | ❌ Not supported | Built-in service discovery | | `entrypoint` | ✅ Supported | Override container entrypoint |