feat(update): add 'uc service update' command to update service configuration in place

This commit is contained in:
Pasha Sviderski
2026-11-03 09:54:33 +10:00
parent c1a7f84dad
commit ff6ac45e1e
7 changed files with 482 additions and 0 deletions
+1
View File
@@ -20,6 +20,7 @@ func NewRootCommand() *cobra.Command {
NewScaleCommand(""),
NewStartCommand(""),
NewStopCommand(""),
NewUpdateCommand(),
)
return cmd
}
+183
View File
@@ -0,0 +1,183 @@
package service
import (
"context"
"errors"
"fmt"
"maps"
"slices"
dockeropts "github.com/docker/cli/opts"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/spf13/cobra"
)
type updateOptions struct {
service string
cpu dockeropts.NanoCPUs
cpuChanged bool
memory dockeropts.MemBytes
memoryChanged bool
memoryReservation dockeropts.MemBytes
reservationChanged bool
labelAdd []string
labelRm []string
}
func NewUpdateCommand() *cobra.Command {
opts := updateOptions{}
cmd := &cobra.Command{
Use: "update SERVICE",
Short: "Update a service in place without recreating its containers.",
Long: `Update service configuration in place without recreating its containers.
A CPU or memory limit can be changed but not removed. To remove a limit or change anything else,
redeploy the service with 'uc deploy'`,
Example: ` # Change the CPU and memory limits.
uc service update web --cpu 0.5 --memory 512m
# Add or change a service label and remove another one.
uc service update web --label-add tier=frontend --label-rm legacy`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
opts.cpuChanged = cmd.Flag("cpu").Changed
opts.memoryChanged = cmd.Flag("memory").Changed
opts.reservationChanged = cmd.Flag("memory-reservation").Changed
return update(cmd.Context(), uncli, opts)
},
ValidArgsFunction: func(
cmd *cobra.Command, args []string, toComplete string,
) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Services(cmd.Context(), uncli, args, toComplete)
},
}
cmd.Flags().Var(&opts.cpu, "cpu",
"Maximum number of CPU cores a service container can use.\n"+
"Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores.")
cmd.Flags().Var(&opts.memory, "memory",
"Maximum amount of memory a service container can use. Value is a positive integer with\n"+
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
"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\n"+
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.")
cmd.Flags().StringArrayVar(&opts.labelAdd, "label-add", nil,
"Add or change a service label. Can be specified multiple times.\n"+
"Format: key=value or just key for an empty value.")
cmd.Flags().StringArrayVar(&opts.labelRm, "label-rm", nil,
"Remove a service label by its key. Can be specified multiple times.")
return cmd
}
func update(ctx context.Context, uncli *cli.CLI, opts updateOptions) error {
if !opts.cpuChanged && !opts.memoryChanged && !opts.reservationChanged &&
len(opts.labelAdd) == 0 && len(opts.labelRm) == 0 {
return errors.New("no changes specified, set at least one of the flags: " +
"--cpu, --memory, --memory-reservation, --label-add, --label-rm")
}
labelAdd, err := parseLabels(opts.labelAdd)
if err != nil {
return err
}
if err = api.ValidateLabels(labelAdd); err != nil {
return fmt.Errorf("invalid service labels: %w", err)
}
for _, key := range opts.labelRm {
if _, ok := labelAdd[key]; ok {
return fmt.Errorf("label '%s' cannot be both added and removed", key)
}
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
svc, err := clusterClient.InspectService(ctx, opts.service)
if err != nil {
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
}
// Check all containers before changing any of them to avoid a partially updated service when a limit can't be
// changed in place. Containers may have different specs after a failed deployment so each one is updated from
// its own spec.
updates := make(map[string]api.UpdateContainerOptions, len(svc.Containers))
for _, mc := range svc.Containers {
spec := applyServiceUpdate(mc.Container.ServiceSpec, opts, labelAdd)
resources := mc.Container.AppliedResources()
if err = resources.ValidateUpdateInPlace(spec.Container.Resources); err != nil {
return fmt.Errorf("container '%s' on machine '%s' can't be updated without recreating it: %w",
stringid.TruncateID(mc.Container.ID), mc.MachineName, err)
}
containerOpts := deploy.ContainerUpdateOptions(mc.Container, spec)
if containerOpts != (api.UpdateContainerOptions{}) {
updates[mc.Container.ID] = containerOpts
}
}
if len(updates) == 0 {
fmt.Printf("Service %s is already up to date. No changes required.\n", tui.NameStyle.Render(svc.Name))
return nil
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
// Keep updating the remaining containers if one fails, for example, because its machine is down.
// Running the command again only updates the containers that still differ.
var errs []error
for id, containerOpts := range updates {
if err := clusterClient.UpdateContainer(ctx, svc.ID, id, containerOpts); err != nil {
errs = append(errs, fmt.Errorf("update container '%s': %w", stringid.TruncateID(id), err))
}
}
return errors.Join(errs...)
}, uncli.ProgressOut(), "Updating service "+tui.NameStyle.Render(svc.Name))
}
// applyServiceUpdate returns a copy of the spec with the requested changes applied.
func applyServiceUpdate(spec api.ServiceSpec, opts updateOptions, labelAdd map[string]string) api.ServiceSpec {
updated := spec.Clone()
if opts.cpuChanged {
updated.Container.Resources.CPU = opts.cpu.Value()
}
if opts.memoryChanged {
updated.Container.Resources.Memory = opts.memory.Value()
}
if opts.reservationChanged {
updated.Container.Resources.MemoryReservation = opts.memoryReservation.Value()
}
if len(labelAdd) > 0 && updated.Labels == nil {
updated.Labels = make(map[string]string, len(labelAdd))
}
maps.Copy(updated.Labels, labelAdd)
for key := range updated.Labels {
if slices.Contains(opts.labelRm, key) {
delete(updated.Labels, key)
}
}
return updated
}
+106
View File
@@ -0,0 +1,106 @@
package service
import (
"testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyServiceUpdate(t *testing.T) {
t.Parallel()
newSpec := func() api.ServiceSpec {
return api.ServiceSpec{
Name: "web",
Container: api.ContainerSpec{
Image: "nginx",
Labels: map[string]string{"container": "keep"},
Resources: api.ContainerResources{
CPU: 100 * api.MilliCore,
Memory: 1024,
MemoryReservation: 512,
},
},
Labels: map[string]string{"tier": "frontend", "legacy": "true"},
}
}
t.Run("adds, changes and removes labels", func(t *testing.T) {
t.Parallel()
spec := newSpec()
updated := applyServiceUpdate(spec, updateOptions{labelRm: []string{"legacy", "missing"}},
map[string]string{"tier": "backend", "team": "core"})
assert.Equal(t, map[string]string{"tier": "backend", "team": "core"}, updated.Labels)
assert.Equal(t, map[string]string{"container": "keep"}, updated.Container.Labels)
assert.Equal(t, spec.Container.Resources, updated.Container.Resources)
assert.Equal(t, map[string]string{"tier": "frontend", "legacy": "true"}, spec.Labels,
"Expected the original spec to stay unchanged")
})
t.Run("adds labels to a spec without labels", func(t *testing.T) {
t.Parallel()
spec := newSpec()
spec.Labels = nil
updated := applyServiceUpdate(spec, updateOptions{}, map[string]string{"tier": "backend"})
assert.Equal(t, map[string]string{"tier": "backend"}, updated.Labels)
})
t.Run("changes only requested limits", func(t *testing.T) {
t.Parallel()
opts := updateOptions{cpuChanged: true}
require.NoError(t, opts.cpu.Set("0.5"))
updated := applyServiceUpdate(newSpec(), opts, nil)
assert.EqualValues(t, 500*api.MilliCore, updated.Container.Resources.CPU)
assert.EqualValues(t, 1024, updated.Container.Resources.Memory)
assert.EqualValues(t, 512, updated.Container.Resources.MemoryReservation)
})
t.Run("sends only changed fields to the container", func(t *testing.T) {
t.Parallel()
opts := updateOptions{memoryChanged: true}
require.NoError(t, opts.memory.Set("2048"))
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(ctr.ServiceSpec, opts, nil))
require.NotNil(t, containerOpts.Memory)
assert.EqualValues(t, 2048, *containerOpts.Memory)
assert.Nil(t, containerOpts.CPU)
assert.Nil(t, containerOpts.MemoryReservation)
assert.Nil(t, containerOpts.ServiceLabels)
})
t.Run("removing the last label clears service labels", func(t *testing.T) {
t.Parallel()
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
ctr.ServiceSpec, updateOptions{labelRm: []string{"tier", "legacy"}}, nil,
))
require.NotNil(t, containerOpts.ServiceLabels)
assert.Empty(t, *containerOpts.ServiceLabels)
})
t.Run("no changes produce empty container options", func(t *testing.T) {
t.Parallel()
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
ctr.ServiceSpec, updateOptions{}, map[string]string{"tier": "frontend"},
))
assert.Equal(t, api.UpdateContainerOptions{}, containerOpts)
})
}
+30
View File
@@ -600,6 +600,36 @@ func (s *Service) FindContainer(nameOrID string) (MachineServiceContainer, error
return MachineServiceContainer{}, ErrNotFound
}
// Spec returns the service spec derived from its containers.
// Containers may have different specs, for example, after a failed deployment. The result is the spec of the newest
// running container, or the newest container if none are running. Replicas is set to the actual number of containers
// for a replicated service.
func (s *Service) Spec() ServiceSpec {
if len(s.Containers) == 0 {
return ServiceSpec{}
}
isRunning := func(c MachineServiceContainer) bool {
return c.Container.State.Running && !c.Container.State.Paused
}
selected := slices.MaxFunc(s.Containers, func(a, b MachineServiceContainer) int {
if aRunning, bRunning := isRunning(a), isRunning(b); aRunning != bRunning {
if aRunning {
return 1
}
return -1
}
return a.Container.CreatedTime().Compare(b.Container.CreatedTime())
})
spec := selected.Container.ServiceSpec.Clone()
// The replicas stored in a container spec go stale when the service is scaled without recreating the container.
if spec.Mode == ServiceModeReplicated {
spec.Replicas = uint(len(s.Containers))
}
return spec
}
// MachineIDs returns a list of unique machine IDs where the service containers are running.
func (s *Service) MachineIDs() []string {
ids := mapset.NewSet[string]()
+107
View File
@@ -404,3 +404,110 @@ func TestContainerSpec_Clone(t *testing.T) {
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"])
})
}
@@ -29,4 +29,5 @@ Manage services in the cluster.
* [uc service scale](uc_service_scale.md) - Scale a replicated service by changing the number of replicas.
* [uc service start](uc_service_start.md) - Start one or more services.
* [uc service stop](uc_service_stop.md) - Stop one or more services.
* [uc service update](uc_service_update.md) - Update a service in place without recreating its containers.
@@ -0,0 +1,54 @@
# uc service update
Update a service in place without recreating its containers.
## Synopsis
Update service configuration in place without recreating its containers.
A CPU or memory limit can be changed but not removed. To remove a limit or change anything else,
redeploy the service with 'uc deploy'
```
uc service update SERVICE [flags]
```
## Examples
```
# Change the CPU and memory limits.
uc service update web --cpu 0.5 --memory 512m
# Add or change a service label and remove another one.
uc service update web --label-add tier=frontend --label-rm legacy
```
## Options
```
--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.
-h, --help help for update
--label-add stringArray Add or change a service label. Can be specified multiple times.
Format: key=value or just key for an empty value.
--label-rm stringArray Remove a service label by its key. Can be specified multiple times.
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with
optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
--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). Default unit is bytes if no suffix specified.
```
## Options inherited from parent commands
```
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
```
## See also
* [uc service](uc_service.md) - Manage services in the cluster.