Files
b7e224a1ef feat(compose): support stdin_open and tty (#419)
* feat: support stdin_open and tty

This can be useful to leave a container running without specifying a
command like `sleep`, and probably in other situations as well.

Signed-off-by: Miek Gieben <miek@miek.nl>

* Complete full spec test case

Signed-off-by: Miek Gieben <miek@miek.nl>

* fix container log streaming for containers with TTY

---------

Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-07-30 21:50:54 +10:00

1296 lines
28 KiB
Go

package compose
import (
"context"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/go-units"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServiceSpecFromCompose(t *testing.T) {
t.Parallel()
initTrue := true
tests := []struct {
name string
filename string
want map[string]api.ServiceSpec
}{
{
name: "minimal",
filename: "compose-minimal.yaml",
want: map[string]api.ServiceSpec{
"test": {
Name: "test",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "nginx:latest",
PullPolicy: api.PullPolicyMissing,
},
},
},
},
{
name: "deploy",
filename: "compose-deploy.yaml",
want: map[string]api.ServiceSpec{
"no-deploy": {
Name: "no-deploy",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "nginx:latest",
PullPolicy: api.PullPolicyMissing,
Resources: api.ContainerResources{
CPU: 1.5 * api.Core,
Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB,
},
},
Replicas: 3,
},
"deploy": {
Name: "deploy",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "nginx:latest",
PullPolicy: api.PullPolicyMissing,
Resources: api.ContainerResources{
CPU: 1.5 * api.Core,
Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB,
},
},
Replicas: 3,
},
"both": {
Name: "both",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "nginx:latest",
PullPolicy: api.PullPolicyMissing,
Resources: api.ContainerResources{
CPU: 2 * api.Core,
Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB,
},
},
Replicas: 3,
},
},
},
{
name: "full spec",
filename: "compose-full-spec.yaml",
want: map[string]api.ServiceSpec{
"test": {
Name: "test",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
CapAdd: []string{"NET_ADMIN"},
CapDrop: []string{"ALL"},
Command: []string{"nginx", "updated", "command"},
Entrypoint: []string{"/updated-docker-entrypoint.sh"},
Env: map[string]string{
"BOOL": "true",
"EMPTY": "",
"VAR": "value",
},
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD", "curl", "-f", "http://localhost"},
Interval: 1*time.Minute + 30*time.Second,
Timeout: 10 * time.Second,
Retries: 5,
StartPeriod: 15 * time.Second,
StartInterval: 2 * time.Second,
},
Image: "nginx:latest",
Init: &initTrue,
LogDriver: &api.LogDriver{
Name: "json-file",
Options: map[string]string{
"max-size": "10m",
"max-file": "3",
},
},
PidMode: "host",
Tty: true,
OpenStdin: true,
Privileged: true,
PullPolicy: api.PullPolicyAlways,
Resources: api.ContainerResources{
CPU: 0.5 * api.Core,
Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB,
SharedMemory: 256 * units.MiB,
Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535},
},
Devices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{Count: -1, Capabilities: [][]string{{"gpu"}}},
},
},
StopGracePeriod: new(30 * time.Second),
Sysctls: map[string]string{
"net.ipv4.ip_forward": "1",
},
User: "nginx:nginx",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
ContainerPath: "/host/etc/passwd",
ReadOnly: true,
},
{
VolumeName: "data1",
ContainerPath: "/data1",
},
{
VolumeName: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
ContainerPath: "/path/in/container",
ReadOnly: true,
},
{
VolumeName: "data3-labeled",
ContainerPath: "/data3",
},
{
VolumeName: "data2-alias",
ContainerPath: "/data2/long/syntax",
},
{
VolumeName: "data-external",
ContainerPath: "/external",
ReadOnly: true,
},
{
VolumeName: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
ContainerPath: "/tmpfs",
},
},
},
Ports: []api.PortSpec{
{
Hostname: "test.example.com",
ContainerPort: 80,
Protocol: api.ProtocolHTTPS,
Mode: api.PortModeIngress,
},
{
ContainerPort: 8000,
Protocol: api.ProtocolHTTP,
Mode: api.PortModeIngress,
},
{
ContainerPort: 3000,
PublishedPort: 5000,
Protocol: "tcp",
Mode: api.PortModeHost,
},
},
Placement: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
PreDeploy: &api.PreDeployHook{
Command: []string{"sh", "-c", "migrate"},
Env: api.EnvVars{"DB_HOST": "localhost"},
Privileged: new(false),
Timeout: new(2*time.Minute + 30*time.Second),
User: "root",
},
Replicas: 3,
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
Volumes: []api.VolumeSpec{
{
Name: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/path/on/host",
CreateHostPath: true,
Propagation: mount.Propagation("rprivate"),
},
},
{
Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/etc/passwd",
CreateHostPath: true,
},
},
{
Name: "data-external",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data-external",
},
},
{
Name: "data1",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data1",
},
},
{
Name: "data2-alias",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data2",
Driver: &mount.Driver{
Name: "local",
},
},
},
{
Name: "data3-labeled",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data3-labeled",
NoCopy: true,
SubPath: "app/data",
Labels: map[string]string{"env": "test"},
},
},
{
Name: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
Type: api.VolumeTypeTmpfs,
TmpfsOptions: &mount.TmpfsOptions{
SizeBytes: 10 * units.MiB,
Mode: os.FileMode(1770),
},
},
},
},
"test-caddy-config": {
Name: "test-caddy-config",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "myapp:1.2.3",
PullPolicy: api.PullPolicyMissing,
},
Caddy: &api.CaddySpec{
Config: `test-caddy-config.example.com {
reverse_proxy {{ upstreams 80 }}
}`,
},
},
},
},
}
for _, tt := range tests {
ctx := context.Background()
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProject(ctx, []string{filepath.Join("testdata", tt.filename)})
require.NoError(t, err)
for name, expectedSpec := range tt.want {
spec, err := ServiceSpecFromCompose(project, name)
require.NoError(t, err)
// Due to the use of a map the order of volumes is non-deterministic.
slices.SortFunc(spec.Volumes, func(a, b api.VolumeSpec) int {
return strings.Compare(a.Name, b.Name)
})
cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{}, netip.Prefix{})}
assert.True(t, cmp.Equal(spec, expectedSpec, cmpOpts...), cmp.Diff(spec, expectedSpec, cmpOpts...))
}
})
}
}
func TestServiceSpecFromCompose_Caddy(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
want *api.CaddySpec
}{
{
name: "x-caddy as string",
composeYAML: `
services:
web:
image: nginx
x-caddy: |
example.com {
reverse_proxy web:80
}
`,
want: &api.CaddySpec{
Config: `example.com {
reverse_proxy web:80
}`,
},
},
{
name: "x-caddy as string with extra spaces",
composeYAML: `
services:
web:
image: nginx
x-caddy: |+
example.com {
reverse_proxy web:80
}
`,
want: &api.CaddySpec{
Config: `example.com {
reverse_proxy web:80
}`,
},
},
{
name: "x-caddy as object with config field",
composeYAML: `
services:
web:
image: nginx
x-caddy:
config: |
example.com {
reverse_proxy web:80
}
`,
want: &api.CaddySpec{
Config: `example.com {
reverse_proxy web:80
}`,
},
},
{
name: "x-caddy as object with config field and extra spaces",
composeYAML: `
services:
web:
image: nginx
x-caddy:
config: |+
example.com {
reverse_proxy web:80
}
`,
want: &api.CaddySpec{
Config: `example.com {
reverse_proxy web:80
}`,
},
},
{
name: "x-caddy with path to Caddyfile",
composeYAML: `
services:
web:
image: nginx
x-caddy: testdata/Caddyfile
`,
want: &api.CaddySpec{
Config: `test.example.com {
reverse_proxy test:8000
}`,
},
},
{
name: "x-caddy with empty string",
composeYAML: `
services:
web:
image: nginx
x-caddy: ""
`,
want: nil,
},
{
name: "no x-caddy extension",
composeYAML: `
services:
web:
image: nginx
`,
want: nil,
},
{
name: "x-caddy with empty object",
composeYAML: `
services:
web:
image: nginx
x-caddy: {}
`,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Get current working directory for relative path resolution to testdata directory.
wd, err := os.Getwd()
require.NoError(t, err)
project, err := LoadProjectFromContent(
context.Background(),
tt.composeYAML,
composecli.WithWorkingDirectory(wd),
)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "web")
require.NoError(t, err)
assert.Equal(t, tt.want, spec.Caddy)
})
}
}
func TestServiceSpecFromCompose_GPUs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedDeviceReqs []container.DeviceRequest
}{
{
name: "gpus_all_shorthand",
composeYAML: `
services:
ai:
image: nvidia/cuda
gpus: all
`,
expectedDeviceReqs: []container.DeviceRequest{
{
Count: -1,
Capabilities: [][]string{{"gpu"}},
},
},
},
{
name: "gpus_device_ids",
composeYAML: `
services:
ai:
image: nvidia/cuda
gpus:
- device_ids: ['0', '1']
capabilities: [compute]
`,
expectedDeviceReqs: []container.DeviceRequest{
{
DeviceIDs: []string{"0", "1"},
Capabilities: [][]string{{"compute", "gpu"}},
},
},
},
{
name: "gpus_count",
composeYAML: `
services:
ai:
image: nvidia/cuda
gpus:
- count: 2
capabilities: [utility]
`,
expectedDeviceReqs: []container.DeviceRequest{
{
Count: 2,
Capabilities: [][]string{{"utility", "gpu"}},
},
},
},
{
name: "gpus_driver_and_options",
composeYAML: `
services:
ai:
image: nvidia/cuda
gpus:
- driver: nvidia
count: 1
capabilities: [compute, utility]
options:
key1: value1
key2: value2
`,
expectedDeviceReqs: []container.DeviceRequest{
{
Driver: "nvidia",
Count: 1,
Capabilities: [][]string{{"compute", "utility", "gpu"}},
Options: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
},
},
{
name: "deploy_resources_reservations_devices",
composeYAML: `
services:
ai:
image: nvidia/cuda
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
`,
expectedDeviceReqs: []container.DeviceRequest{
{
Driver: "nvidia",
Count: 1,
Capabilities: [][]string{{"gpu"}},
},
},
},
{
name: "gpus_with_existing_gpu_capability",
composeYAML: `
services:
ai:
image: nvidia/cuda
gpus:
- capabilities: [gpu, compute]
`,
expectedDeviceReqs: []container.DeviceRequest{
{
// defaults to "all" when count not specified
Count: -1,
// gpu appended even if already present (matching Docker Compose behavior)
Capabilities: [][]string{{"gpu", "compute", "gpu"}},
},
},
},
{
name: "multiple_device_reservations",
composeYAML: `
services:
ai:
image: nvidia/cuda
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 2
capabilities: [gpu, compute]
- capabilities: [tpu]
count: 1
`,
expectedDeviceReqs: []container.DeviceRequest{
{
Driver: "nvidia",
Count: 2,
Capabilities: [][]string{{"gpu", "compute"}},
},
{
Count: 1,
Capabilities: [][]string{{"tpu"}},
},
},
},
{
name: "tpu_reservation",
composeYAML: `
services:
ai:
image: tensorflow/tensorflow:latest
deploy:
resources:
reservations:
devices:
- capabilities: [tpu]
count: 4
driver: google
`,
expectedDeviceReqs: []container.DeviceRequest{
{
Driver: "google",
Count: 4,
Capabilities: [][]string{{"tpu"}},
},
},
},
}
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, "ai")
require.NoError(t, err)
assert.Equal(t, tt.expectedDeviceReqs, spec.Container.Resources.DeviceReservations,
"DeviceReservations should match expected")
})
}
}
func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedVolume api.VolumeSpec
}{
{
name: "volume with driver_opts only (no driver specified)",
composeYAML: `
services:
test:
image: nginx
volumes:
- nfsmount:/data/nfs
volumes:
nfsmount:
driver_opts:
type: "nfs"
o: "addr=192.168.1.100,nolock,soft"
device: ":/mnt/share"
`,
expectedVolume: api.VolumeSpec{
Name: "nfsmount",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "nfsmount",
Driver: &mount.Driver{
Options: map[string]string{
"type": "nfs",
"o": "addr=192.168.1.100,nolock,soft",
"device": ":/mnt/share",
},
},
},
},
},
{
name: "volume with driver and driver_opts",
composeYAML: `
services:
test:
image: nginx
volumes:
- nfsmount:/data/nfs
volumes:
nfsmount:
driver: local
driver_opts:
type: "nfs"
o: "addr=192.168.1.100,nolock,soft"
device: ":/mnt/share"
`,
expectedVolume: api.VolumeSpec{
Name: "nfsmount",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "nfsmount",
Driver: &mount.Driver{
Name: "local",
Options: map[string]string{
"type": "nfs",
"o": "addr=192.168.1.100,nolock,soft",
"device": ":/mnt/share",
},
},
},
},
},
{
name: "external volume ignores driver_opts",
composeYAML: `
services:
test:
image: nginx
volumes:
- extvolume:/data
volumes:
extvolume:
external: true
`,
expectedVolume: api.VolumeSpec{
Name: "extvolume",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "extvolume",
},
},
},
}
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)
require.Len(t, spec.Volumes, 1, "expected exactly one volume")
actualVolume := spec.Volumes[0]
cmpOpts := cmp.Options{cmpopts.EquateEmpty()}
assert.True(t, cmp.Equal(actualVolume, tt.expectedVolume, cmpOpts...),
"Volume mismatch:\n%s", cmp.Diff(actualVolume, tt.expectedVolume, cmpOpts...))
})
}
}
func TestServiceSpecFromCompose_Ulimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected map[string]api.Ulimit
}{
{
name: "single ulimit with soft and hard limits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
},
},
{
name: "single ulimit with single value (soft=hard)",
composeYAML: `
services:
db:
image: postgres
ulimits:
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "multiple ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "empty ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits: {}
`,
expected: nil,
},
}
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, "db")
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.Container.Resources.Ulimits)
})
}
}
func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected api.UpdateConfig
expectError bool
}{
{
name: "no update_config",
composeYAML: `
services:
test:
image: nginx
`,
expected: api.UpdateConfig{},
},
{
name: "empty update_config",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config: {}
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with unsupported attributes ignored",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
parallelism: 1
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with stop-first order",
composeYAML: `
services:
test:
image: postgres
deploy:
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with start-first order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with invalid order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: invalid-order
`,
expectError: true,
},
{
name: "update_config with replicas and order",
composeYAML: `
services:
test:
image: nginx
deploy:
replicas: 3
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with custom monitor",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 10s
`,
expected: api.UpdateConfig{
MonitorPeriod: new(10 * time.Second),
},
},
{
name: "update_config with monitor and order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
monitor: 30s
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: new(30 * time.Second),
},
},
{
name: "update_config with zero monitor skips monitoring",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 0s
`,
expected: api.UpdateConfig{
MonitorPeriod: new(time.Duration(0)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.UpdateConfig)
})
}
}
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected api.Placement
expectError bool
}{
{
name: "valid x-machines with string array",
composeYAML: `
services:
test:
image: nginx
x-machines: ["machine-1", "machine-2"]
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
},
{
name: "valid x-machines with single string",
composeYAML: `
services:
test:
image: nginx
x-machines: my-machine
`,
expected: api.Placement{
Machines: []string{"my-machine"},
},
},
{
name: "valid x-machines with single quoted string",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1"
`,
expected: api.Placement{
Machines: []string{"machine-1"},
},
},
{
name: "valid x-machines with numeric string",
composeYAML: `
services:
test:
image: nginx
x-machines: "123"
`,
expected: api.Placement{
Machines: []string{"123"},
},
},
{
name: "valid x-machines with comma-separated string",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1,machine-2"
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
},
{
name: "valid x-machines with comma-separated string and spaces",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1, machine-2, machine-3"
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2", "machine-3"},
},
},
{
name: "empty x-machines array",
composeYAML: `
services:
test:
image: nginx
x-machines: []
`,
expected: api.Placement{
Machines: []string{},
},
},
{
name: "no x-machines",
composeYAML: `
services:
test:
image: nginx
`,
expected: api.Placement{},
},
{
name: "empty machine name in x-machines",
composeYAML: `
services:
test:
image: nginx
x-machines: ["machine-1", "", "machine-2"]
`,
expectError: true,
},
{
name: "empty machine name in comma-separated x-machines",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1,,machine-2"
`,
expectError: true,
},
{
name: "x-machines with whitespace trimming",
composeYAML: `
services:
test:
image: nginx
x-machines: [" machine-1 ", "machine-2"]
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
// Convert to ServiceSpec
spec, err := ServiceSpecFromCompose(project, "test")
require.NoError(t, err)
if len(tt.expected.Machines) == 0 && len(spec.Placement.Machines) == 0 {
// Both are empty, consider them equal
return
}
assert.Equal(t, tt.expected, spec.Placement)
})
}
}
func TestServiceSpecFromCompose_Devices(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedDevices []api.DeviceMapping
expectedReservations []container.DeviceRequest
}{
{
name: "simple device",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "device with target and permissions",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
},
{
name: "multiple devices",
composeYAML: `
services:
test:
image: nginx
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0:rw"
- /dev/sda:/dev/xvda
- "/dev/dri"
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "CDI device",
composeYAML: `
services:
test:
image: nginx
devices:
- vendor.com/class=device1
`,
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1"}},
},
},
{
name: "mixed CDI and regular devices",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
- vendor.com/class=device1
- nvidia.com/gpu=0
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1", "nvidia.com/gpu=0"}},
},
},
}
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.expectedDevices, spec.Container.Resources.Devices)
assert.Equal(t, tt.expectedReservations, spec.Container.Resources.DeviceReservations)
})
}
}