feat: add support for entrypoint, cpus, mem_limit, mem_reservation, priviliged, user to Compose deployment

This commit is contained in:
Pavel Sviderski
2025-04-24 22:40:46 +10:00
parent 6c2759f315
commit 938e7d05c0
6 changed files with 361 additions and 2 deletions
+46 -1
View File
@@ -44,24 +44,41 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
spec := api.ServiceSpec{
Container: api.ContainerSpec{
Command: service.Command,
Entrypoint: service.Entrypoint,
Env: env,
Image: service.Image,
Init: service.Init,
Privileged: service.Privileged,
PullPolicy: pullPolicy,
Resources: resourcesFromCompose(service),
User: service.User,
},
Name: serviceName,
Mode: api.ServiceModeReplicated,
// TODO: implement and map x-machines to Placement.
}
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
spec.Ports = ports
}
// Map LogDriver if specified
if service.Logging != nil && service.Logging.Driver != "" {
spec.Container.LogDriver = &api.LogDriver{
Name: service.Logging.Driver,
Options: service.Logging.Options,
}
}
if service.Scale != nil {
spec.Replicas = uint(*service.Scale)
}
if service.Deploy != nil {
switch service.Deploy.Mode {
case "global":
spec.Mode = api.ServiceModeGlobal
case "", "replicated":
spec.Mode = api.ServiceModeReplicated
if service.Deploy.Replicas != nil {
spec.Replicas = uint(*service.Deploy.Replicas)
}
@@ -82,6 +99,34 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
return spec, nil
}
func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
resources := api.ContainerResources{
CPU: int64(service.CPUS * 1e9),
Memory: int64(service.MemLimit),
MemoryReservation: int64(service.MemReservation),
}
// Map resources from deploy section if specified.
if service.Deploy != nil {
if service.Deploy.Resources.Limits != nil {
if service.Deploy.Resources.Limits.NanoCPUs > 0 {
// It seems Limits.NanoCPUs is actually not nano CPUs but a CPU fraction.
resources.CPU = int64(service.Deploy.Resources.Limits.NanoCPUs * 1e9)
}
if service.Deploy.Resources.Limits.MemoryBytes > 0 {
resources.Memory = int64(service.Deploy.Resources.Limits.MemoryBytes)
}
}
if service.Deploy.Resources.Reservations != nil {
if service.Deploy.Resources.Reservations.MemoryBytes > 0 {
resources.MemoryReservation = int64(service.Deploy.Resources.Reservations.MemoryBytes)
}
}
}
return resources
}
func volumeSpecsFromCompose(
volumes types.Volumes, serviceVolumes []types.ServiceVolumeConfig,
) ([]api.VolumeSpec, []api.VolumeMount, error) {
+238
View File
@@ -0,0 +1,238 @@
package compose
import (
"context"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
"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"
)
// loadProjectFromFile loads a compose project from a YAML file
func loadProjectFromFile(t *testing.T, filename string) *types.Project {
t.Helper()
ctx := context.Background()
path := filepath.Join("testdata", filename)
options, err := cli.NewProjectOptions(
[]string{path},
cli.WithName(FakeProjectName),
cli.WithOsEnv,
cli.WithDotEnv,
)
require.NoError(t, err)
project, err := options.LoadProject(ctx)
require.NoError(t, err)
return project
}
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{
Command: []string{"nginx", "updated", "command"},
Entrypoint: []string{"/updated-docker-entrypoint.sh"},
Env: map[string]string{
"BOOL": "true",
"EMPTY": "",
"VAR": "value",
},
Image: "nginx:latest",
Init: &initTrue,
LogDriver: &api.LogDriver{
Name: "json-file",
Options: map[string]string{
"max-size": "10m",
"max-file": "3",
},
},
Privileged: true,
PullPolicy: api.PullPolicyAlways,
Resources: api.ContainerResources{
CPU: 0.5 * api.Core,
Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB,
},
User: "nginx:nginx",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
ContainerPath: "/host/etc/passwd",
ReadOnly: true,
},
{
VolumeName: "data1",
ContainerPath: "/data1",
},
{
VolumeName: "data2-alias",
ContainerPath: "/data2/long/syntax",
},
{
VolumeName: "data-external",
ContainerPath: "/external",
ReadOnly: true,
},
{
VolumeName: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
ContainerPath: "/tmpfs",
},
},
},
Replicas: 3,
Volumes: []api.VolumeSpec{
{
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: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
Type: api.VolumeTypeTmpfs,
TmpfsOptions: &mount.TmpfsOptions{
SizeBytes: 10 * units.MiB,
},
},
},
},
},
},
}
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)
})
assert.True(t, cmp.Equal(spec, expectedSpec, cmpopts.EquateEmpty()),
cmp.Diff(spec, expectedSpec, cmpopts.EquateEmpty()))
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
services:
no-deploy:
image: nginx:latest
cpus: 1.5
mem_limit: 100M
mem_reservation: 50M
scale: 3
deploy:
image: nginx:latest
deploy:
replicas: 3
resources:
limits:
cpus: 1.5
memory: 100M
reservations:
memory: 50M
both:
image: nginx:latest
cpus: 2
mem_limit: 102400K
mem_reservation: 52428800
scale: 3
deploy:
replicas: 3
resources:
limits:
cpus: 2.0
memory: 100M
reservations:
memory: 50M
+41
View File
@@ -0,0 +1,41 @@
services:
test:
command: ["nginx", "updated", "command"]
cpus: 0.5
entrypoint: ["/updated-docker-entrypoint.sh"]
environment:
BOOL: "true"
EMPTY: ""
VAR: value
image: nginx:latest
init: true
logging:
driver: json-file
options:
max-size: 10m
max-file: 3
mem_limit: 100M
mem_reservation: 50M
privileged: true
pull_policy: always
scale: 3
user: nginx:nginx
volumes:
- /etc/passwd:/host/etc/passwd:ro
- data1:/data1
- type: volume
source: data2-alias
target: /data2/long/syntax
- data-external:/external:ro
- type: tmpfs
target: /tmpfs
tmpfs:
size: 10485760
volumes:
data1:
data2-alias:
name: data2
driver: local
data-external:
external: true
+3
View File
@@ -0,0 +1,3 @@
services:
test:
image: nginx:latest
-1
View File
@@ -1311,7 +1311,6 @@ func TestEvalContainerSpecChange_Mixed(t *testing.T) {
new api.ServiceSpec
want ContainerSpecStatus
}{
{
name: "mutable changes",
current: api.ServiceSpec{