From 938e7d05c009e9f38750843dd907acb43b67f298 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Thu, 24 Apr 2025 22:40:46 +1000 Subject: [PATCH] feat: add support for entrypoint, cpus, mem_limit, mem_reservation, priviliged, user to Compose deployment --- pkg/client/compose/service.go | 47 +++- pkg/client/compose/service_test.go | 238 ++++++++++++++++++ .../compose/testdata/compose-deploy.yaml | 33 +++ .../compose/testdata/compose-full-spec.yaml | 41 +++ .../compose/testdata/compose-minimal.yaml | 3 + pkg/client/deploy/container_test.go | 1 - 6 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 pkg/client/compose/service_test.go create mode 100644 pkg/client/compose/testdata/compose-deploy.yaml create mode 100644 pkg/client/compose/testdata/compose-full-spec.yaml create mode 100644 pkg/client/compose/testdata/compose-minimal.yaml diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index cf4792cf..6dc9d0ff 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -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) { diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go new file mode 100644 index 00000000..071dc824 --- /dev/null +++ b/pkg/client/compose/service_test.go @@ -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())) + } + }) + } +} diff --git a/pkg/client/compose/testdata/compose-deploy.yaml b/pkg/client/compose/testdata/compose-deploy.yaml new file mode 100644 index 00000000..b0d172eb --- /dev/null +++ b/pkg/client/compose/testdata/compose-deploy.yaml @@ -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 diff --git a/pkg/client/compose/testdata/compose-full-spec.yaml b/pkg/client/compose/testdata/compose-full-spec.yaml new file mode 100644 index 00000000..25b0ea0f --- /dev/null +++ b/pkg/client/compose/testdata/compose-full-spec.yaml @@ -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 diff --git a/pkg/client/compose/testdata/compose-minimal.yaml b/pkg/client/compose/testdata/compose-minimal.yaml new file mode 100644 index 00000000..337fcbde --- /dev/null +++ b/pkg/client/compose/testdata/compose-minimal.yaml @@ -0,0 +1,3 @@ +services: + test: + image: nginx:latest diff --git a/pkg/client/deploy/container_test.go b/pkg/client/deploy/container_test.go index f806a4c6..607f98cb 100644 --- a/pkg/client/deploy/container_test.go +++ b/pkg/client/deploy/container_test.go @@ -1311,7 +1311,6 @@ func TestEvalContainerSpecChange_Mixed(t *testing.T) { new api.ServiceSpec want ContainerSpecStatus }{ - { name: "mutable changes", current: api.ServiceSpec{