mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
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:
@@ -617,6 +617,7 @@ func (s *Server) CreateServiceContainer(
|
|||||||
NanoCPUs: spec.Container.Resources.CPU,
|
NanoCPUs: spec.Container.Resources.CPU,
|
||||||
Memory: spec.Container.Resources.Memory,
|
Memory: spec.Container.Resources.Memory,
|
||||||
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
||||||
|
Devices: spec.Container.Resources.DeviceMappings,
|
||||||
DeviceRequests: spec.Container.Resources.DeviceReservations,
|
DeviceRequests: spec.Container.Resources.DeviceReservations,
|
||||||
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
|
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type ContainerResources struct {
|
|||||||
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
|
// 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.
|
// TODO: implement a placement constraint that checks available memory on machines.
|
||||||
MemoryReservation int64
|
MemoryReservation int64
|
||||||
|
// Device mappings for direct access to host devices
|
||||||
|
DeviceMappings []container.DeviceMapping
|
||||||
// Device reservations/requests for access to things like GPUs
|
// Device reservations/requests for access to things like GPUs
|
||||||
DeviceReservations []container.DeviceRequest
|
DeviceReservations []container.DeviceRequest
|
||||||
// Ulimits defines the resource limits for the container.
|
// Ulimits defines the resource limits for the container.
|
||||||
|
|||||||
@@ -383,6 +383,12 @@ func (s *ContainerSpec) Clone() ContainerSpec {
|
|||||||
if s.Resources.Ulimits != nil {
|
if s.Resources.Ulimits != nil {
|
||||||
spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits)
|
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
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -231,6 +232,12 @@ func TestContainerSpec_Clone(t *testing.T) {
|
|||||||
CPU: 1234,
|
CPU: 1234,
|
||||||
Memory: 2345,
|
Memory: 2345,
|
||||||
MemoryReservation: 3456,
|
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{
|
Sysctls: map[string]string{
|
||||||
"net.ipv4.ip_forward": "1",
|
"net.ipv4.ip_forward": "1",
|
||||||
@@ -263,6 +270,9 @@ func TestContainerSpec_Clone(t *testing.T) {
|
|||||||
original.ConfigMounts[0].ContainerPath = stringModified
|
original.ConfigMounts[0].ContainerPath = stringModified
|
||||||
*original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value
|
*original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value
|
||||||
original.Sysctls["net.ipv4.ip_forward"] = stringModified
|
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.False(t, original.Equals(cloned))
|
||||||
// Assert cloned values are unchanged
|
// 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(1234), cloned.Resources.CPU)
|
||||||
assert.Equal(t, int64(2345), cloned.Resources.Memory)
|
assert.Equal(t, int64(2345), cloned.Resources.Memory)
|
||||||
assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation)
|
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, "1000:1000", cloned.User)
|
||||||
assert.Equal(t, "/data", cloned.Volumes[0])
|
assert.Equal(t, "/data", cloned.Volumes[0])
|
||||||
assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath)
|
assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
|
|||||||
Memory: int64(service.MemLimit),
|
Memory: int64(service.MemLimit),
|
||||||
MemoryReservation: int64(service.MemReservation),
|
MemoryReservation: int64(service.MemReservation),
|
||||||
Ulimits: ulimitsFromCompose(service.Ulimits),
|
Ulimits: ulimitsFromCompose(service.Ulimits),
|
||||||
|
DeviceMappings: devicesFromCompose(service.Devices),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert GPU device requests from compose format, appending "gpu" capability.
|
// 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
|
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.
|
// validateServicesExtensions validates extension combinations across all services in the project.
|
||||||
func validateServicesExtensions(project *types.Project) error {
|
func validateServicesExtensions(project *types.Project) error {
|
||||||
for _, service := range project.Services {
|
for _, service := range project.Services {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) {
|
||||||
return ContainerNeedsRecreate
|
return ContainerNeedsRecreate
|
||||||
}
|
}
|
||||||
|
if !reflect.DeepEqual(current.Container.Resources.DeviceMappings, newResources.DeviceMappings) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any mutable properties changed.
|
// Check if any mutable properties changed.
|
||||||
if !current.Caddy.Equals(new.Caddy) {
|
if !current.Caddy.Equals(new.Caddy) {
|
||||||
|
|||||||
@@ -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) {
|
func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ The following table shows the support status for main Compose features:
|
|||||||
| `configs` | ✅ Supported | File-based and inline configs |
|
| `configs` | ✅ Supported | File-based and inline configs |
|
||||||
| `cpus` | ✅ Supported | CPU limit |
|
| `cpus` | ✅ Supported | CPU limit |
|
||||||
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
|
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
|
||||||
|
| `devices` | ✅ Supported | Device mappings |
|
||||||
| `dns` | ❌ Not supported | Built-in service discovery |
|
| `dns` | ❌ Not supported | Built-in service discovery |
|
||||||
| `dns_search` | ❌ Not supported | Built-in service discovery |
|
| `dns_search` | ❌ Not supported | Built-in service discovery |
|
||||||
| `entrypoint` | ✅ Supported | Override container entrypoint |
|
| `entrypoint` | ✅ Supported | Override container entrypoint |
|
||||||
|
|||||||
Reference in New Issue
Block a user