feat(labels): add support for service and container labels in uc run and inspect commands

This commit is contained in:
Pasha Sviderski
2026-11-03 09:54:00 +10:00
parent 81f30aac26
commit c1a7f84dad
7 changed files with 598 additions and 91 deletions
+18
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"slices" "slices"
"time" "time"
@@ -58,6 +59,16 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
fmt.Printf("Service ID: %s\n", svc.ID) fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name) fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode) fmt.Printf("Mode: %s\n", svc.Mode)
spec := svc.Spec()
if len(spec.Labels) > 0 {
fmt.Println("Labels:")
printLabels(spec.Labels)
}
if len(spec.Container.Labels) > 0 {
fmt.Println("Container labels:")
printLabels(spec.Container.Labels)
}
fmt.Println() fmt.Println()
// Combine regular and hook containers. // Combine regular and hook containers.
@@ -130,3 +141,10 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
lipgloss.Println(t) lipgloss.Println(t)
return nil return nil
} }
// printLabels prints the labels sorted by key followed by a blank line.
func printLabels(labels map[string]string) {
for _, key := range slices.Sorted(maps.Keys(labels)) {
lipgloss.Println(" " + key + tui.Faint.Render("=") + labels[key])
}
}
+46 -4
View File
@@ -21,13 +21,16 @@ import (
type runOptions struct { type runOptions struct {
caddyfile string caddyfile string
command []string command []string
containerLabels []string
cpu dockeropts.NanoCPUs cpu dockeropts.NanoCPUs
entrypoint string entrypoint string
entrypointChanged bool entrypointChanged bool
env []string env []string
image string image string
labels []string
machines []string machines []string
memory dockeropts.MemBytes memory dockeropts.MemBytes
memoryReservation dockeropts.MemBytes
mode string mode string
shmSize dockeropts.MemBytes shmSize dockeropts.MemBytes
name string name string
@@ -64,6 +67,9 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "", cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
"Path to a custom Caddy config (Caddyfile) for the service. "+ "Path to a custom Caddy config (Caddyfile) for the service. "+
"Cannot be used together with non-@host published ports.") "Cannot be used together with non-@host published ports.")
cmd.Flags().StringArrayVar(&opts.containerLabels, "container-label", nil,
"Set a Docker label on service containers. Can be specified multiple times.\n"+
"Format: key=value or just key for an empty value.")
cmd.Flags().VarP(&opts.cpu, "cpu", "", cmd.Flags().VarP(&opts.cpu, "cpu", "",
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+ "Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
"0.5 for half a core or 2.25 for two and a quarter cores.") "0.5 for half a core or 2.25 for two and a quarter cores.")
@@ -72,6 +78,9 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil, cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil,
"Set an environment variable for service containers. Can be specified multiple times.\n"+ "Set an environment variable for service containers. Can be specified multiple times.\n"+
"Format: VAR=value or just VAR to use the value from the local environment.") "Format: VAR=value or just VAR to use the value from the local environment.")
cmd.Flags().StringArrayVarP(&opts.labels, "label", "l", nil,
"Set a service label. Service labels aren't applied to Docker containers, use --container-label for that.\n"+
"Can be specified multiple times. Format: key=value or just key for an empty value.")
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated, cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+ fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
"the machines) or '%s' (one container on every machine).", "the machines) or '%s' (one container on every machine).",
@@ -83,6 +92,9 @@ func NewRunCommand(groupID string) *cobra.Command {
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+ "Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"Default unit is bytes if no suffix specified.\n"+ "Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)") "Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().Var(&opts.memoryReservation, "memory-reservation",
"Minimum amount of memory reserved for a service container. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"Default unit is bytes if no suffix specified.")
cmd.Flags().VarP(&opts.shmSize, "shm-size", "", cmd.Flags().VarP(&opts.shmSize, "shm-size", "",
"Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+ "Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+
"with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+ "with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
@@ -192,6 +204,15 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
return spec, err return spec, err
} }
labels, err := parseLabels(opts.labels)
if err != nil {
return spec, err
}
containerLabels, err := parseLabels(opts.containerLabels)
if err != nil {
return spec, err
}
switch opts.mode { switch opts.mode {
case api.ServiceModeReplicated, api.ServiceModeGlobal: case api.ServiceModeReplicated, api.ServiceModeGlobal:
default: default:
@@ -232,17 +253,20 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
Command: opts.command, Command: opts.command,
Env: env, Env: env,
Image: opts.image, Image: opts.image,
Labels: containerLabels,
Privileged: opts.privileged, Privileged: opts.privileged,
PullPolicy: opts.pull, PullPolicy: opts.pull,
Resources: api.ContainerResources{ Resources: api.ContainerResources{
CPU: opts.cpu.Value(), CPU: opts.cpu.Value(),
Memory: opts.memory.Value(), Memory: opts.memory.Value(),
SharedMemory: opts.shmSize.Value(), MemoryReservation: opts.memoryReservation.Value(),
Ulimits: ulimits, SharedMemory: opts.shmSize.Value(),
Ulimits: ulimits,
}, },
User: opts.user, User: opts.user,
VolumeMounts: mounts, VolumeMounts: mounts,
}, },
Labels: labels,
Mode: opts.mode, Mode: opts.mode,
Name: opts.name, Name: opts.name,
Placement: placement, Placement: placement,
@@ -301,6 +325,24 @@ func parseEnv(env []string) (api.EnvVars, error) {
return envVars, nil return envVars, nil
} }
// parseLabels parses labels from the command line arguments in the format "key=value" or just "key" for an empty value.
func parseLabels(labels []string) (map[string]string, error) {
if len(labels) == 0 {
return nil, nil
}
result := make(map[string]string, len(labels))
for _, label := range labels {
key, value, _ := strings.Cut(label, "=")
if strings.TrimSpace(key) == "" {
return nil, fmt.Errorf("invalid label '%s': key must not be empty", label)
}
result[key] = value
}
return result, nil
}
// parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts. // parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts.
// It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy]) // It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
// and bind mounts (/host/path:/container/path[:ro]). // and bind mounts (/host/path:/container/path[:ro]).
+423
View File
@@ -0,0 +1,423 @@
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)
})
}
}
+6 -1
View File
@@ -8,6 +8,7 @@ import (
"slices" "slices"
"strings" "strings"
"time" "time"
"unicode"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/distribution/reference" "github.com/distribution/reference"
@@ -443,12 +444,16 @@ func (s *ContainerSpec) Clone() ContainerSpec {
return spec return spec
} }
// ValidateLabels checks that label keys are non-empty and don't use the reserved Uncloud prefixes. // ValidateLabels checks that label keys are non-empty, don't contain whitespace, and don't use the reserved Uncloud
// prefixes.
func ValidateLabels(labels map[string]string) error { func ValidateLabels(labels map[string]string) error {
for k := range labels { for k := range labels {
if strings.TrimSpace(k) == "" { if strings.TrimSpace(k) == "" {
return fmt.Errorf("label key must not be empty") return fmt.Errorf("label key must not be empty")
} }
if strings.ContainsFunc(k, unicode.IsSpace) {
return fmt.Errorf("label '%s' must not contain whitespace", k)
}
if strings.HasPrefix(k, LabelReservedPrefix) || strings.HasPrefix(k, LabelDaemonReservedPrefix) { if strings.HasPrefix(k, LabelReservedPrefix) || strings.HasPrefix(k, LabelDaemonReservedPrefix) {
return fmt.Errorf("label '%s' cannot use the reserved prefix '%s' or '%s'", return fmt.Errorf("label '%s' cannot use the reserved prefix '%s' or '%s'",
k, LabelReservedPrefix, LabelDaemonReservedPrefix) k, LabelReservedPrefix, LabelDaemonReservedPrefix)
+7
View File
@@ -74,7 +74,14 @@ func TestServiceSpec_Validate_Labels(t *testing.T) {
{name: "nil labels"}, {name: "nil labels"},
{name: "valid labels", labels: map[string]string{"com.example.team": "web", "tier": ""}}, {name: "valid labels", labels: map[string]string{"com.example.team": "web", "tier": ""}},
{name: "prefix without dot", labels: map[string]string{"uncloudfoo": "bar"}}, {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: "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 prefix", labels: map[string]string{LabelManaged: ""}, wantErr: "reserved prefix"},
{name: "reserved daemon prefix", labels: map[string]string{"uncloudd.foo": "bar"}, wantErr: "reserved prefix"}, {name: "reserved daemon prefix", labels: map[string]string{"uncloudd.foo": "bar"}, wantErr: "reserved prefix"},
} }
+49 -43
View File
@@ -9,49 +9,55 @@ uc run IMAGE [COMMAND...] [flags]
## Options ## Options
``` ```
--caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports. --caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports.
--cpu decimal Maximum number of CPU cores a service container can use. Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores. --container-label stringArray Set a Docker label on service containers. Can be specified multiple times.
--entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it. Format: key=value or just key for an empty value.
-e, --env strings Set an environment variable for service containers. Can be specified multiple times. --cpu decimal Maximum number of CPU cores a service container can use. Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores.
Format: VAR=value or just VAR to use the value from the local environment. --entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it.
-h, --help help for run -e, --env strings Set an environment variable for service containers. Can be specified multiple times.
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine) Format: VAR=value or just VAR to use the value from the local environment.
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g). -h, --help help for run
Default unit is bytes if no suffix specified. -l, --label stringArray Set a service label. Service labels aren't applied to Docker containers, use --container-label for that.
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte) Can be specified multiple times. Format: key=value or just key for an empty value.
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated") -m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
-n, --name string Assign a name to the service. A random name is generated if not specified. --memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
--privileged Give extended privileges to service containers. This is a security risk and should be used with caution. Default unit is bytes if no suffix specified.
-p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times. Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host --memory-reservation bytes Minimum amount of memory reserved for a service container. Value is a positive integer with optional unit suffix (b, k, m, g).
Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified Default unit is bytes if no suffix specified.
and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname. --mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
Examples: -n, --name string Assign a name to the service. A random name is generated if not specified.
-p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname --privileged Give extended privileges to service containers. This is a security risk and should be used with caution.
-p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname -p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times.
-p 53:5353/udp@host Bind UDP port 5353 to host port 53 Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host
-p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified
contained in the prefix 192.168.76.0/24 and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing") Examples:
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1) -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified. -p 53:5353/udp@host Bind UDP port 5353 to host port 53
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte) -p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address
--ulimit strings Set resource limits for service containers. Can be specified multiple times. contained in the prefix 192.168.76.0/24
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both. --pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
Examples: --replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files --shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers. Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image. --ulimit strings Set resource limits for service containers. Can be specified multiple times.
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
the volume is located. Can be specified multiple times. Examples:
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro] --ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
Host and container paths support runtime templates such as {{.Container.Name}}. --ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
Examples: -u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container -v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only the volume is located. Can be specified multiple times.
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
Host and container paths support runtime templates such as {{.Container.Name}}.
Examples:
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only
``` ```
## Options inherited from parent commands ## Options inherited from parent commands
+49 -43
View File
@@ -9,49 +9,55 @@ uc service run IMAGE [COMMAND...] [flags]
## Options ## Options
``` ```
--caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports. --caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports.
--cpu decimal Maximum number of CPU cores a service container can use. Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores. --container-label stringArray Set a Docker label on service containers. Can be specified multiple times.
--entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it. Format: key=value or just key for an empty value.
-e, --env strings Set an environment variable for service containers. Can be specified multiple times. --cpu decimal Maximum number of CPU cores a service container can use. Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores.
Format: VAR=value or just VAR to use the value from the local environment. --entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it.
-h, --help help for run -e, --env strings Set an environment variable for service containers. Can be specified multiple times.
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine) Format: VAR=value or just VAR to use the value from the local environment.
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g). -h, --help help for run
Default unit is bytes if no suffix specified. -l, --label stringArray Set a service label. Service labels aren't applied to Docker containers, use --container-label for that.
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte) Can be specified multiple times. Format: key=value or just key for an empty value.
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated") -m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
-n, --name string Assign a name to the service. A random name is generated if not specified. --memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
--privileged Give extended privileges to service containers. This is a security risk and should be used with caution. Default unit is bytes if no suffix specified.
-p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times. Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host --memory-reservation bytes Minimum amount of memory reserved for a service container. Value is a positive integer with optional unit suffix (b, k, m, g).
Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified Default unit is bytes if no suffix specified.
and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname. --mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
Examples: -n, --name string Assign a name to the service. A random name is generated if not specified.
-p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname --privileged Give extended privileges to service containers. This is a security risk and should be used with caution.
-p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname -p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times.
-p 53:5353/udp@host Bind UDP port 5353 to host port 53 Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host
-p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified
contained in the prefix 192.168.76.0/24 and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing") Examples:
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1) -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified. -p 53:5353/udp@host Bind UDP port 5353 to host port 53
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte) -p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address
--ulimit strings Set resource limits for service containers. Can be specified multiple times. contained in the prefix 192.168.76.0/24
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both. --pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
Examples: --replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files --shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers. Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image. --ulimit strings Set resource limits for service containers. Can be specified multiple times.
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
the volume is located. Can be specified multiple times. Examples:
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro] --ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
Host and container paths support runtime templates such as {{.Container.Name}}. --ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
Examples: -u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container -v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only the volume is located. Can be specified multiple times.
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
Host and container paths support runtime templates such as {{.Container.Name}}.
Examples:
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only
``` ```
## Options inherited from parent commands ## Options inherited from parent commands