mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-10 20:54:17 +00:00
424 lines
12 KiB
Go
424 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/docker/go-units"
|
|
"github.com/psviderski/uncloud/pkg/api"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// validRunOptions returns the minimal options that produce a valid service spec.
|
|
func validRunOptions() runOptions {
|
|
return runOptions{
|
|
image: "nginx:1.27",
|
|
name: "web",
|
|
mode: api.ServiceModeReplicated,
|
|
pull: api.PullPolicyMissing,
|
|
replicas: 1,
|
|
}
|
|
}
|
|
|
|
func TestParseLabels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
labels, err := parseLabels(nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, labels)
|
|
|
|
labels, err = parseLabels([]string{"tier=frontend", "empty", "list=a,b", "expr=a=b", "tier=backend"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]string{
|
|
"tier": "backend",
|
|
"empty": "",
|
|
"list": "a,b",
|
|
"expr": "a=b",
|
|
}, labels)
|
|
|
|
_, err = parseLabels([]string{"=value"})
|
|
require.ErrorContains(t, err, "key must not be empty")
|
|
|
|
_, err = parseLabels([]string{" \t =value"})
|
|
require.ErrorContains(t, err, "key must not be empty")
|
|
|
|
labels, err = parseLabels([]string{"key=value with spaces"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]string{"key": "value with spaces"}, labels)
|
|
}
|
|
|
|
func TestPrepareServiceSpec(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("minimal options", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
spec, err := prepareServiceSpec(validRunOptions())
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "web", spec.Name)
|
|
assert.Equal(t, api.ServiceModeReplicated, spec.Mode)
|
|
assert.EqualValues(t, 1, spec.Replicas)
|
|
assert.Equal(t, "nginx:1.27", spec.Container.Image)
|
|
assert.Equal(t, api.PullPolicyMissing, spec.Container.PullPolicy)
|
|
assert.Nil(t, spec.Caddy)
|
|
assert.Nil(t, spec.Labels)
|
|
assert.Nil(t, spec.Container.Labels)
|
|
assert.Nil(t, spec.Container.Entrypoint)
|
|
assert.Nil(t, spec.Container.Resources.Ulimits)
|
|
assert.Nil(t, spec.Placement.Machines)
|
|
assert.Empty(t, spec.Container.Env)
|
|
assert.Empty(t, spec.Ports)
|
|
assert.Empty(t, spec.Volumes)
|
|
assert.Empty(t, spec.Container.VolumeMounts)
|
|
})
|
|
|
|
t.Run("all options", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.command = []string{"nginx", "-g", "daemon off;"}
|
|
opts.env = []string{"FOO=bar", "EMPTY=", "EXPR=a=b"}
|
|
opts.labels = []string{"tier=frontend"}
|
|
opts.containerLabels = []string{"logging=enabled"}
|
|
opts.machines = []string{"m1,m2", "m3"}
|
|
opts.privileged = true
|
|
opts.pull = api.PullPolicyAlways
|
|
opts.publish = []string{"app.example.com:8080/https", "53:5353/udp@host"}
|
|
opts.replicas = 3
|
|
opts.ulimits = []string{"nofile=1024:2048", "nproc=65535"}
|
|
opts.user = "1000:1000"
|
|
opts.volumes = []string{"data:/var/lib/data:ro"}
|
|
require.NoError(t, opts.cpu.Set("0.5"))
|
|
require.NoError(t, opts.memory.Set("128m"))
|
|
require.NoError(t, opts.memoryReservation.Set("32m"))
|
|
require.NoError(t, opts.shmSize.Set("64m"))
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, api.ServiceSpec{
|
|
Container: api.ContainerSpec{
|
|
Command: []string{"nginx", "-g", "daemon off;"},
|
|
Env: api.EnvVars{"FOO": "bar", "EMPTY": "", "EXPR": "a=b"},
|
|
Image: "nginx:1.27",
|
|
Labels: map[string]string{"logging": "enabled"},
|
|
Privileged: true,
|
|
PullPolicy: api.PullPolicyAlways,
|
|
Resources: api.ContainerResources{
|
|
CPU: 500 * api.MilliCore,
|
|
Memory: 128 * units.MiB,
|
|
MemoryReservation: 32 * units.MiB,
|
|
SharedMemory: 64 * units.MiB,
|
|
Ulimits: map[string]api.Ulimit{
|
|
"nofile": {Soft: 1024, Hard: 2048},
|
|
"nproc": {Soft: 65535, Hard: 65535},
|
|
},
|
|
},
|
|
User: "1000:1000",
|
|
VolumeMounts: []api.VolumeMount{
|
|
{VolumeName: "data", ContainerPath: "/var/lib/data", ReadOnly: true},
|
|
},
|
|
},
|
|
Labels: map[string]string{"tier": "frontend"},
|
|
Mode: api.ServiceModeReplicated,
|
|
Name: "web",
|
|
Placement: api.Placement{Machines: []string{"m1", "m2", "m3"}},
|
|
Ports: []api.PortSpec{
|
|
{
|
|
Hostname: "app.example.com",
|
|
ContainerPort: 8080,
|
|
Protocol: api.ProtocolHTTPS,
|
|
Mode: api.PortModeIngress,
|
|
},
|
|
{
|
|
PublishedPort: 53,
|
|
ContainerPort: 5353,
|
|
Protocol: api.ProtocolUDP,
|
|
Mode: api.PortModeHost,
|
|
},
|
|
},
|
|
Replicas: 3,
|
|
Volumes: []api.VolumeSpec{
|
|
{
|
|
Name: "data",
|
|
Type: api.VolumeTypeVolume,
|
|
VolumeOptions: &api.VolumeOptions{Name: "data"},
|
|
},
|
|
},
|
|
}, spec)
|
|
})
|
|
|
|
t.Run("generates a name when not specified", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.name = ""
|
|
opts.image = "bitnami/nginx:latest"
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Regexp(t, `^nginx-[a-z0-9]{4}$`, spec.Name)
|
|
})
|
|
|
|
t.Run("keeps the specified name", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.name = "custom"
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "custom", spec.Name)
|
|
})
|
|
|
|
t.Run("global mode", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.mode = api.ServiceModeGlobal
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, api.ServiceModeGlobal, spec.Mode)
|
|
})
|
|
|
|
t.Run("entrypoint", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.entrypoint = "/bin/sh"
|
|
opts.entrypointChanged = true
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"/bin/sh"}, spec.Container.Entrypoint)
|
|
|
|
// An explicitly empty entrypoint resets the one from the image.
|
|
opts = validRunOptions()
|
|
opts.entrypointChanged = true
|
|
spec, err = prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{""}, spec.Container.Entrypoint)
|
|
})
|
|
|
|
t.Run("env without a value is omitted when not set locally", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.env = []string{"UNCLOUD_TEST_ENV_THAT_IS_NOT_SET"}
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, spec.Container.Env, "UNCLOUD_TEST_ENV_THAT_IS_NOT_SET")
|
|
})
|
|
|
|
t.Run("bind mount", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.volumes = []string{"/data/uploads:/app/uploads"}
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, spec.Volumes, 1)
|
|
assert.Regexp(t, `^bind-[a-z0-9]{4}$`, spec.Volumes[0].Name)
|
|
assert.Equal(t, api.VolumeTypeBind, spec.Volumes[0].Type)
|
|
require.NotNil(t, spec.Volumes[0].BindOptions)
|
|
assert.Equal(t, "/data/uploads", spec.Volumes[0].BindOptions.HostPath)
|
|
assert.True(t, spec.Volumes[0].BindOptions.CreateHostPath)
|
|
|
|
require.Len(t, spec.Container.VolumeMounts, 1)
|
|
assert.Equal(t, spec.Volumes[0].Name, spec.Container.VolumeMounts[0].VolumeName)
|
|
assert.Equal(t, "/app/uploads", spec.Container.VolumeMounts[0].ContainerPath)
|
|
})
|
|
|
|
t.Run("named volume mounted twice creates one volume", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
opts.volumes = []string{"data:/a", "data:/b"}
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Len(t, spec.Volumes, 1)
|
|
assert.Len(t, spec.Container.VolumeMounts, 2)
|
|
})
|
|
|
|
t.Run("caddyfile content is trimmed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "Caddyfile")
|
|
require.NoError(t, os.WriteFile(path, []byte("\nexample.com {\n}\n\n"), 0o600))
|
|
|
|
opts := validRunOptions()
|
|
opts.caddyfile = path
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, spec.Caddy)
|
|
assert.Equal(t, "example.com {\n}", spec.Caddy.Config)
|
|
})
|
|
|
|
t.Run("empty caddyfile is ignored", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "Caddyfile")
|
|
require.NoError(t, os.WriteFile(path, []byte(" \n"), 0o600))
|
|
|
|
opts := validRunOptions()
|
|
opts.caddyfile = path
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, spec.Caddy)
|
|
})
|
|
|
|
t.Run("caddyfile can be used with host ports", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "Caddyfile")
|
|
require.NoError(t, os.WriteFile(path, []byte("example.com {\n}"), 0o600))
|
|
|
|
opts := validRunOptions()
|
|
opts.caddyfile = path
|
|
opts.publish = []string{"53:5353/udp@host"}
|
|
|
|
_, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestPrepareServiceSpec_LocalEnv(t *testing.T) {
|
|
// Not parallel because t.Setenv changes the environment of the whole process.
|
|
t.Setenv("UNCLOUD_TEST_LOCAL_ENV", "from-local")
|
|
|
|
opts := validRunOptions()
|
|
opts.env = []string{"UNCLOUD_TEST_LOCAL_ENV"}
|
|
|
|
spec, err := prepareServiceSpec(opts)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "from-local", spec.Container.Env["UNCLOUD_TEST_LOCAL_ENV"])
|
|
}
|
|
|
|
func TestPrepareServiceSpec_Invalid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
caddyfile := filepath.Join(t.TempDir(), "Caddyfile")
|
|
require.NoError(t, os.WriteFile(caddyfile, []byte("example.com {\n}"), 0o600))
|
|
|
|
tests := []struct {
|
|
name string
|
|
modify func(opts *runOptions)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "invalid image",
|
|
modify: func(opts *runOptions) { opts.image = "INVALID IMAGE" },
|
|
wantErr: "invalid image",
|
|
},
|
|
{
|
|
name: "invalid name",
|
|
modify: func(opts *runOptions) { opts.name = "Not_Valid" },
|
|
wantErr: "invalid service name",
|
|
},
|
|
{
|
|
name: "invalid mode",
|
|
modify: func(opts *runOptions) { opts.mode = "unknown" },
|
|
wantErr: "invalid replication mode: 'unknown'",
|
|
},
|
|
{
|
|
name: "invalid pull policy",
|
|
modify: func(opts *runOptions) { opts.pull = "sometimes" },
|
|
wantErr: "invalid pull policy: 'sometimes'",
|
|
},
|
|
{
|
|
name: "invalid env",
|
|
modify: func(opts *runOptions) { opts.env = []string{"=value"} },
|
|
wantErr: "invalid environment variable: '=value'",
|
|
},
|
|
{
|
|
name: "invalid port",
|
|
modify: func(opts *runOptions) { opts.publish = []string{"not-a-port"} },
|
|
wantErr: "invalid service port 'not-a-port'",
|
|
},
|
|
{
|
|
name: "invalid ulimit",
|
|
modify: func(opts *runOptions) { opts.ulimits = []string{"nofile"} },
|
|
wantErr: "invalid ulimit 'nofile'",
|
|
},
|
|
{
|
|
name: "volume without a container path",
|
|
modify: func(opts *runOptions) { opts.volumes = []string{"data"} },
|
|
wantErr: "invalid volume mount 'data'",
|
|
},
|
|
{
|
|
name: "volume with a relative container path",
|
|
modify: func(opts *runOptions) { opts.volumes = []string{"data:relative"} },
|
|
wantErr: "must be absolute path",
|
|
},
|
|
{
|
|
name: "volume with an unknown option",
|
|
modify: func(opts *runOptions) { opts.volumes = []string{"data:/data:unknown"} },
|
|
wantErr: "invalid option: 'unknown'",
|
|
},
|
|
{
|
|
name: "missing caddyfile",
|
|
modify: func(opts *runOptions) { opts.caddyfile = filepath.Join(os.TempDir(), "uncloud-test-no-such-file") },
|
|
wantErr: "read Caddyfile",
|
|
},
|
|
{
|
|
name: "caddyfile with an ingress port",
|
|
modify: func(opts *runOptions) {
|
|
opts.caddyfile = caddyfile
|
|
opts.publish = []string{"app.example.com:8080/https"}
|
|
},
|
|
wantErr: "cannot be specified simultaneously",
|
|
},
|
|
{
|
|
name: "service label without a key",
|
|
modify: func(opts *runOptions) { opts.labels = []string{"=value"} },
|
|
wantErr: "key must not be empty",
|
|
},
|
|
{
|
|
name: "container label without a key",
|
|
modify: func(opts *runOptions) { opts.containerLabels = []string{"=value"} },
|
|
wantErr: "key must not be empty",
|
|
},
|
|
{
|
|
name: "service label with whitespace",
|
|
modify: func(opts *runOptions) { opts.labels = []string{"my label=value"} },
|
|
wantErr: "invalid service labels: label 'my label' must not contain whitespace",
|
|
},
|
|
{
|
|
name: "container label with whitespace",
|
|
modify: func(opts *runOptions) { opts.containerLabels = []string{"my label=value"} },
|
|
wantErr: "invalid container labels: label 'my label' must not contain whitespace",
|
|
},
|
|
{
|
|
name: "reserved service label",
|
|
modify: func(opts *runOptions) { opts.labels = []string{api.LabelManaged + "=true"} },
|
|
wantErr: "invalid service labels",
|
|
},
|
|
{
|
|
name: "reserved container label",
|
|
modify: func(opts *runOptions) { opts.containerLabels = []string{api.LabelManaged + "=true"} },
|
|
wantErr: "invalid container labels",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := validRunOptions()
|
|
tt.modify(&opts)
|
|
|
|
_, err := prepareServiceSpec(opts)
|
|
require.ErrorContains(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
}
|