mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-10 12:44:16 +00:00
184 lines
6.5 KiB
Go
184 lines
6.5 KiB
Go
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
|
|
}
|