package api import ( "os" "strings" "testing" "github.com/docker/docker/api/types/container" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestServiceSpec_Validate_Name(t *testing.T) { t.Parallel() tests := []struct { name string input string wantErr string }{ {name: "empty allows generated name"}, {name: "single character", input: "a"}, {name: "single digit", input: "1"}, {name: "two characters", input: "a1"}, {name: "hyphens and digits", input: "1-web-2"}, {name: "consecutive hyphens", input: "web--1"}, {name: "maximum length", input: strings.Repeat("a", 63)}, {name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"}, {name: "round-robin mode is a valid service name", input: "rr"}, {name: "nearest mode is a valid service name", input: "nearest"}, {name: "machine namespace prefix", input: "m-service"}, {name: "short hexadecimal name", input: strings.Repeat("a", 31)}, {name: "long hexadecimal name", input: strings.Repeat("a", 33)}, {name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)}, {name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"}, {name: "uppercase", input: "WEB1", wantErr: "lowercase letters"}, {name: "leading hyphen", input: "-web", wantErr: "starting and ending"}, {name: "trailing hyphen", input: "web-", wantErr: "starting and ending"}, {name: "hyphen only", input: "-", wantErr: "starting and ending"}, {name: "underscore", input: "web_1", wantErr: "hyphens only"}, {name: "dot", input: "web.example", wantErr: "hyphens only"}, {name: "slash", input: "web/api", wantErr: "hyphens only"}, {name: "space", input: "web 1", wantErr: "hyphens only"}, {name: "leading whitespace", input: " web", wantErr: "hyphens only"}, {name: "trailing whitespace", input: "web ", wantErr: "hyphens only"}, {name: "tab", input: "web\t1", wantErr: "hyphens only"}, {name: "newline", input: "web\n", wantErr: "hyphens only"}, {name: "non-ASCII", input: "wéb", wantErr: "lowercase letters"}, {name: "machine DNS namespace", input: "m", wantErr: "reserved for the machine DNS namespace"}, {name: "service ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "service ID format"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() spec := ServiceSpec{Name: tt.input, Container: ContainerSpec{Image: "nginx:latest"}} err := spec.Validate() if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) } else { require.NoError(t, err) } }) } } func TestServiceSpec_Validate_Labels(t *testing.T) { t.Parallel() tests := []struct { name string labels map[string]string wantErr string }{ {name: "nil labels"}, {name: "valid labels", labels: map[string]string{"com.example.team": "web", "tier": ""}}, {name: "prefix without dot", labels: map[string]string{"uncloudfoo": "bar"}}, {name: "whitespace in value", labels: map[string]string{"tier": "value with spaces"}}, {name: "empty key", labels: map[string]string{"": "bar"}, wantErr: "must not be empty"}, {name: "only whitespace in key", labels: map[string]string{" ": "bar"}, wantErr: "must not be empty"}, {name: "space in key", labels: map[string]string{"my label": "bar"}, wantErr: "must not contain whitespace"}, {name: "leading space in key", labels: map[string]string{" tier": "bar"}, wantErr: "must not contain whitespace"}, {name: "trailing space in key", labels: map[string]string{"tier ": "bar"}, wantErr: "must not contain whitespace"}, {name: "tab in key", labels: map[string]string{"my\tlabel": "bar"}, wantErr: "must not contain whitespace"}, {name: "newline in key", labels: map[string]string{"my\nlabel": "bar"}, wantErr: "must not contain whitespace"}, {name: "reserved prefix", labels: map[string]string{LabelManaged: ""}, wantErr: "reserved prefix"}, {name: "reserved daemon prefix", labels: map[string]string{"uncloudd.foo": "bar"}, wantErr: "reserved prefix"}, } for _, tt := range tests { t.Run(tt.name+"/service", func(t *testing.T) { t.Parallel() spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest"}, Labels: tt.labels} err := spec.Validate() if tt.wantErr != "" { require.ErrorContains(t, err, "invalid service labels") require.ErrorContains(t, err, tt.wantErr) } else { require.NoError(t, err) } }) t.Run(tt.name+"/container", func(t *testing.T) { t.Parallel() spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest", Labels: tt.labels}} err := spec.Validate() if tt.wantErr != "" { require.ErrorContains(t, err, "invalid container labels") require.ErrorContains(t, err, tt.wantErr) } else { require.NoError(t, err) } }) } } func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) { tests := []struct { name string spec ServiceSpec wantErr string }{ { name: "valid with neither Caddy nor Ports", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, }, wantErr: "", }, { name: "valid with Caddy only", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Caddy: &CaddySpec{ Config: "example.com {\n reverse_proxy :8080\n}", }, }, wantErr: "", }, { name: "valid with Ports only", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Ports: []PortSpec{ { ContainerPort: 80, Protocol: ProtocolHTTP, }, }, }, wantErr: "", }, { name: "valid with empty Caddy config and Ports", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Caddy: &CaddySpec{ Config: "", }, Ports: []PortSpec{ { ContainerPort: 80, Protocol: ProtocolHTTP, }, }, }, wantErr: "", }, { name: "invalid with Caddy and Ports (default mode is ingress)", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Caddy: &CaddySpec{ Config: "example.com {\n reverse_proxy :8080\n}", }, Ports: []PortSpec{ { ContainerPort: 80, Protocol: ProtocolHTTP, // Mode is empty, defaults to ingress }, }, }, wantErr: "ingress ports and Caddy configuration cannot be specified simultaneously", }, { name: "invalid with both Caddy and ingress Ports", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Caddy: &CaddySpec{ Config: "example.com {\n reverse_proxy :8080\n}", }, Ports: []PortSpec{ { ContainerPort: 80, Protocol: ProtocolHTTP, Mode: PortModeIngress, }, }, }, wantErr: "ingress ports and Caddy configuration cannot be specified simultaneously", }, { name: "valid with Caddy and host mode Ports", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Caddy: &CaddySpec{ Config: "example.com {\n reverse_proxy :8080\n}", }, Ports: []PortSpec{ { ContainerPort: 3306, PublishedPort: 3306, Protocol: ProtocolTCP, Mode: PortModeHost, }, }, }, wantErr: "", }, { name: "invalid with Caddy and mixed mode Ports", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Caddy: &CaddySpec{ Config: "example.com {\n reverse_proxy :8080\n}", }, Ports: []PortSpec{ { ContainerPort: 3306, PublishedPort: 3306, Protocol: ProtocolTCP, Mode: PortModeHost, }, { ContainerPort: 80, Protocol: ProtocolHTTP, Mode: PortModeIngress, }, }, }, wantErr: "ingress ports and Caddy configuration cannot be specified simultaneously", }, { name: "valid with Caddy and multiple host mode Ports", spec: ServiceSpec{ Name: "test", Container: ContainerSpec{ Image: "nginx:latest", }, Caddy: &CaddySpec{ Config: "example.com {\n reverse_proxy :8080\n}", }, Ports: []PortSpec{ { ContainerPort: 3306, PublishedPort: 3306, Protocol: ProtocolTCP, Mode: PortModeHost, }, { ContainerPort: 5432, PublishedPort: 5432, Protocol: ProtocolTCP, Mode: PortModeHost, }, }, }, wantErr: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.spec.Validate() if tt.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, tt.wantErr) } }) } } func TestContainerSpec_Clone(t *testing.T) { mode := os.FileMode(0o644) original := ContainerSpec{ CapAdd: []string{"NET_ADMIN"}, CapDrop: []string{"ALL"}, PidMode: "host", Command: []string{"sh", "-c", "echo hello"}, Entrypoint: []string{"/bin/bash"}, Env: EnvVars{ "FOO": "bar", "BAZ": "qux", }, Image: "nginx:latest", Init: new(true), Labels: map[string]string{"com.example.team": "web"}, LogDriver: &LogDriver{ Name: "json-file", Options: map[string]string{ "max-size": "10m", }, }, Privileged: true, PullPolicy: PullPolicyAlways, Resources: ContainerResources{ CPU: 1234, Memory: 2345, MemoryReservation: 3456, Devices: []DeviceMapping{ {HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"}, }, DeviceReservations: []container.DeviceRequest{ {Count: 1, Capabilities: [][]string{{"gpu"}}, Driver: "nvidia"}, }, }, Sysctls: map[string]string{ "net.ipv4.ip_forward": "1", }, User: "1000:1000", Volumes: []string{"/data", "/config"}, VolumeMounts: []VolumeMount{ {VolumeName: "data", ContainerPath: "/data"}, }, ConfigMounts: []ConfigMount{ {ConfigName: "app-config", ContainerPath: "/etc/config", Mode: &mode}, }, } cloned := original.Clone() // Check ContainerSpec equality assert.True(t, original.Equals(cloned)) // Verify deep copy by modifying the original stringModified := "modified" original.CapAdd[0] = stringModified original.CapDrop[0] = stringModified original.Command[0] = stringModified original.Entrypoint[0] = stringModified original.Env["FOO"] = stringModified original.Labels["com.example.team"] = stringModified original.LogDriver.Options["max-size"] = stringModified original.Volumes[0] = stringModified original.VolumeMounts[0].ContainerPath = stringModified original.ConfigMounts[0].ContainerPath = stringModified *original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value original.Sysctls["net.ipv4.ip_forward"] = stringModified original.Resources.Devices[0].HostPath = stringModified original.Resources.DeviceReservations[0].Count = 2 original.Resources.DeviceReservations[0].Driver = stringModified assert.False(t, original.Equals(cloned)) // Assert cloned values are unchanged assert.Equal(t, "NET_ADMIN", cloned.CapAdd[0]) assert.Equal(t, "ALL", cloned.CapDrop[0]) assert.Equal(t, "sh", cloned.Command[0]) assert.Equal(t, "web", cloned.Labels["com.example.team"]) assert.Equal(t, "/bin/bash", cloned.Entrypoint[0]) assert.Equal(t, "bar", cloned.Env["FOO"]) assert.Equal(t, "qux", cloned.Env["BAZ"]) assert.Equal(t, "nginx:latest", cloned.Image) assert.NotNil(t, cloned.Init) assert.Equal(t, true, *cloned.Init) assert.NotNil(t, cloned.LogDriver) assert.Equal(t, "json-file", cloned.LogDriver.Name) assert.Equal(t, "10m", cloned.LogDriver.Options["max-size"]) assert.Equal(t, true, cloned.Privileged) assert.Equal(t, PullPolicyAlways, cloned.PullPolicy) assert.Equal(t, int64(1234), cloned.Resources.CPU) assert.Equal(t, int64(2345), cloned.Resources.Memory) assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation) assert.Equal(t, "/dev/sda", cloned.Resources.Devices[0].HostPath) 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, "/data", cloned.Volumes[0]) assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath) assert.Equal(t, "/etc/config", cloned.ConfigMounts[0].ContainerPath) assert.NotNil(t, cloned.ConfigMounts[0].Mode) assert.Equal(t, os.FileMode(0o644), *cloned.ConfigMounts[0].Mode, "Mode should be deep copied") assert.Equal(t, "1", cloned.Sysctls["net.ipv4.ip_forward"]) } func TestService_Spec(t *testing.T) { t.Parallel() newContainer := func(name, created string, running bool, mode string, replicas uint) MachineServiceContainer { return MachineServiceContainer{Container: ServiceContainer{ Container: Container{InspectResponse: container.InspectResponse{ ContainerJSONBase: &container.ContainerJSONBase{ Created: created, State: &container.State{Running: running}, }, }}, ServiceSpec: ServiceSpec{ Labels: map[string]string{"from": name}, Mode: mode, Replicas: replicas, }, }} } const ( older = "2026-01-01T00:00:00Z" newer = "2026-01-02T00:00:00Z" ) t.Run("no containers", func(t *testing.T) { t.Parallel() svc := Service{HookContainers: []MachineServiceContainer{ newContainer("hook", newer, true, ServiceModeReplicated, 1), }} assert.Equal(t, ServiceSpec{}, svc.Spec()) }) t.Run("newest running container wins", func(t *testing.T) { t.Parallel() svc := Service{Containers: []MachineServiceContainer{ newContainer("old-running", older, true, ServiceModeReplicated, 1), newContainer("new-running", newer, true, ServiceModeReplicated, 1), }} spec := svc.Spec() assert.Equal(t, "new-running", spec.Labels["from"]) }) t.Run("running container wins over newer stopped one", func(t *testing.T) { t.Parallel() svc := Service{Containers: []MachineServiceContainer{ newContainer("new-stopped", newer, false, ServiceModeReplicated, 1), newContainer("old-running", older, true, ServiceModeReplicated, 1), }} spec := svc.Spec() assert.Equal(t, "old-running", spec.Labels["from"]) }) t.Run("newest container wins when none are running", func(t *testing.T) { t.Parallel() svc := Service{Containers: []MachineServiceContainer{ newContainer("old-stopped", older, false, ServiceModeReplicated, 1), newContainer("new-stopped", newer, false, ServiceModeReplicated, 1), }} spec := svc.Spec() assert.Equal(t, "new-stopped", spec.Labels["from"]) }) t.Run("replicas match the number of containers", func(t *testing.T) { t.Parallel() svc := Service{Containers: []MachineServiceContainer{ newContainer("a", older, true, ServiceModeReplicated, 5), newContainer("b", newer, true, ServiceModeReplicated, 5), newContainer("c", newer, false, ServiceModeReplicated, 5), }} spec := svc.Spec() assert.EqualValues(t, 3, spec.Replicas) }) t.Run("global service has no replicas", func(t *testing.T) { t.Parallel() svc := Service{Containers: []MachineServiceContainer{ newContainer("a", older, true, ServiceModeGlobal, 0), newContainer("b", newer, true, ServiceModeGlobal, 0), }} spec := svc.Spec() assert.Zero(t, spec.Replicas) }) t.Run("result is independent of the container spec", func(t *testing.T) { t.Parallel() svc := Service{Containers: []MachineServiceContainer{ newContainer("a", older, true, ServiceModeReplicated, 1), }} spec := svc.Spec() spec.Labels["from"] = "changed" assert.Equal(t, "a", svc.Containers[0].Container.ServiceSpec.Labels["from"]) }) }