From ff6ac45e1ee3b552435183322f42a1f64326449b Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Tue, 3 Nov 2026 09:54:33 +1000 Subject: [PATCH] feat(update): add 'uc service update' command to update service configuration in place --- cmd/uc/service/root.go | 1 + cmd/uc/service/update.go | 183 ++++++++++++++++++ cmd/uc/service/update_test.go | 106 ++++++++++ pkg/api/service.go | 30 +++ pkg/api/service_test.go | 107 ++++++++++ website/docs/9-cli-reference/uc_service.md | 1 + .../docs/9-cli-reference/uc_service_update.md | 54 ++++++ 7 files changed, 482 insertions(+) create mode 100644 cmd/uc/service/update.go create mode 100644 cmd/uc/service/update_test.go create mode 100644 website/docs/9-cli-reference/uc_service_update.md diff --git a/cmd/uc/service/root.go b/cmd/uc/service/root.go index e33ad8ee..57b1965e 100644 --- a/cmd/uc/service/root.go +++ b/cmd/uc/service/root.go @@ -20,6 +20,7 @@ func NewRootCommand() *cobra.Command { NewScaleCommand(""), NewStartCommand(""), NewStopCommand(""), + NewUpdateCommand(), ) return cmd } diff --git a/cmd/uc/service/update.go b/cmd/uc/service/update.go new file mode 100644 index 00000000..3ec1b31a --- /dev/null +++ b/cmd/uc/service/update.go @@ -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 +} diff --git a/cmd/uc/service/update_test.go b/cmd/uc/service/update_test.go new file mode 100644 index 00000000..db1d5861 --- /dev/null +++ b/cmd/uc/service/update_test.go @@ -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) + }) +} diff --git a/pkg/api/service.go b/pkg/api/service.go index 671683b1..d8d910ff 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -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]() diff --git a/pkg/api/service_test.go b/pkg/api/service_test.go index 8bd3fbe8..d07c7e61 100644 --- a/pkg/api/service_test.go +++ b/pkg/api/service_test.go @@ -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"]) + }) +} diff --git a/website/docs/9-cli-reference/uc_service.md b/website/docs/9-cli-reference/uc_service.md index c3613d96..b284bad3 100644 --- a/website/docs/9-cli-reference/uc_service.md +++ b/website/docs/9-cli-reference/uc_service.md @@ -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. diff --git a/website/docs/9-cli-reference/uc_service_update.md b/website/docs/9-cli-reference/uc_service_update.md new file mode 100644 index 00000000..9c39e007 --- /dev/null +++ b/website/docs/9-cli-reference/uc_service_update.md @@ -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. +