mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-10 04:34:17 +00:00
feat(update): add 'uc service update' command to update service configuration in place
This commit is contained in:
@@ -20,6 +20,7 @@ func NewRootCommand() *cobra.Command {
|
||||
NewScaleCommand(""),
|
||||
NewStartCommand(""),
|
||||
NewStopCommand(""),
|
||||
NewUpdateCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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]()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user