From 586a4fa129bfe5b95d8b9fe9bdbc69aa63e67be0 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Wed, 5 Mar 2025 20:24:54 +1000 Subject: [PATCH] feat(scale): basic service scale command --- cmd/uncloud/caddy/deploy.go | 25 ++--- cmd/uncloud/main.go | 1 + cmd/uncloud/service/root.go | 1 + cmd/uncloud/service/scale.go | 161 +++++++++++++++++++++++++++++++ internal/cli/client/operation.go | 19 ++++ 5 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 cmd/uncloud/service/scale.go diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index 1b9a41a9..dfa6baef 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -100,21 +100,6 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { return fmt.Errorf("create caddy deployment: %w", err) } - // Initialize a machine and container name resolver to properly format the plan output. - machines, err := clusterClient.ListMachines(ctx) - if err != nil { - return fmt.Errorf("list machines: %w", err) - } - machineNames := make(map[string]string, len(machines)) - for _, m := range machines { - machineNames[m.Machine.Id] = m.Machine.Name - } - containerNames := make(map[string]string, len(svc.Containers)) - for _, c := range svc.Containers { - containerNames[c.Container.ID] = c.Container.NameWithoutSlash() - } - resolver := client.NewNameResolver(machineNames, containerNames) - if opts.image == "" { fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image) } @@ -127,7 +112,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { return fmt.Errorf("plan caddy deployment: %w", err) } - if len(plan.SequenceOperation.Operations) == 0 { + if len(plan.Operations) == 0 { if opts.machine != "" { fmt.Printf("%s service is up to date on selected machines.\n", client.CaddyServiceName) } else { @@ -147,8 +132,14 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { fmt.Println("This will perform a rolling update of Caddy containers on each machine.") } } - fmt.Println() + // Initialise a machine and container name resolver to properly format the plan output. + resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc) + if err != nil { + return fmt.Errorf("create machine and container name resolver for service operations: %w", err) + } + + fmt.Println() fmt.Println("Deployment plan:") fmt.Println(plan.Format(resolver)) fmt.Println() diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 56d735b1..db8e410d 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -51,6 +51,7 @@ func main() { service.NewListCommand(), service.NewRmCommand(), service.NewRunCommand(), + service.NewScaleCommand(), ) cobra.CheckErr(cmd.Execute()) } diff --git a/cmd/uncloud/service/root.go b/cmd/uncloud/service/root.go index 5329876b..bf694356 100644 --- a/cmd/uncloud/service/root.go +++ b/cmd/uncloud/service/root.go @@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command { NewListCommand(), NewRmCommand(), NewRunCommand(), + NewScaleCommand(), ) return cmd } diff --git a/cmd/uncloud/service/scale.go b/cmd/uncloud/service/scale.go new file mode 100644 index 00000000..f0d1a25f --- /dev/null +++ b/cmd/uncloud/service/scale.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "fmt" + "github.com/charmbracelet/huh" + "github.com/docker/compose/v2/pkg/progress" + "github.com/spf13/cobra" + "strconv" + "uncloud/internal/api" + "uncloud/internal/cli" +) + +type scaleOptions struct { + service string + replicas uint + cluster string +} + +func NewScaleCommand() *cobra.Command { + opts := scaleOptions{} + cmd := &cobra.Command{ + Use: "scale SERVICE REPLICAS", + Short: "Scale a replicated service by changing the number of replicas.", + Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + + opts.service = args[0] + replicas, err := strconv.ParseUint(args[1], 10, 0) + if err != nil { + return fmt.Errorf("invalid number of replicas: %w", err) + } + opts.replicas = uint(replicas) + + return scale(cmd.Context(), uncli, opts) + }, + } + + cmd.Flags().StringVarP( + &opts.cluster, "cluster", "c", "", + "Name of the cluster. (default is the current cluster)", + ) + + return cmd +} + +func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error { + if opts.replicas == 0 { + return fmt.Errorf( + "scaling to zero replicas is not supported. This would effectively remove the service without preserving "+ + "its configuration, making it impossible to scale back up. Uncloud derives the service configuration "+ + "from existing containers. Use 'uc rm %s' instead if you want to remove the service", + opts.service, + ) + } + + clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster) + 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) + } + + if svc.Mode != api.ServiceModeReplicated { + return fmt.Errorf("scaling is only supported for services in %s mode, service '%s' is in %s mode", + api.ServiceModeReplicated, svc.Name, svc.Mode) + } + + currentReplicas := uint(len(svc.Containers)) + + if currentReplicas == opts.replicas { + fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas) + return nil + } + + // TODO: Check if all containers have the same spec. If not, prompt user to choose which one to scale. + // This can happen if a service deployment failed midway and some containers were not updated. + // Derive the service spec from the first container. + spec, err := svc.Containers[0].Container.ServiceSpec() + if err != nil { + return fmt.Errorf("get service spec from container: %w", err) + } + + spec.Replicas = opts.replicas + + deploy, err := clusterClient.NewDeployment(spec, nil) + if err != nil { + return fmt.Errorf("create deployment: %w", err) + } + + plan, err := deploy.Plan(ctx) + if err != nil { + return fmt.Errorf("plan deployment: %w", err) + } + + if len(plan.Operations) == 0 { + fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas) + return nil + } + + if opts.replicas < currentReplicas { + // Initialise a machine and container name resolver to properly format the plan output. + resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc) + if err != nil { + return fmt.Errorf("create machine and container name resolver for service operations: %w", err) + } + + fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas) + fmt.Println(plan.Format(resolver)) + fmt.Println() + + // Ask for confirmation before scaling down as it may cause data loss. + confirmed, err := confirm() + if err != nil { + return fmt.Errorf("confirm scaling: %w", err) + } + if !confirmed { + fmt.Println("Cancelled. No changes were made.") + return nil + } + } + + title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas) + err = progress.RunWithTitle(ctx, func(ctx context.Context) error { + if _, err = deploy.Run(ctx); err != nil { + return fmt.Errorf("deploy service: %w", err) + } + return nil + }, uncli.ProgressOut(), title) + if err != nil { + return err + } + + return nil +} + +func confirm() (bool, error) { + var confirmed bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title( + "Do you want to continue?", + ). + Affirmative("Yes!"). + Negative("No"). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + return false, err + } + + return confirmed, nil +} diff --git a/internal/cli/client/operation.go b/internal/cli/client/operation.go index bc3cf595..5db7b1ac 100644 --- a/internal/cli/client/operation.go +++ b/internal/cli/client/operation.go @@ -164,3 +164,22 @@ func (r *MapNameResolver) ContainerName(containerID string) string { } return containerID } + +// ServiceOperationNameResolver returns a machine and container name resolver for a service that can be used to format +// deployment operations. +func (cli *Client) ServiceOperationNameResolver(ctx context.Context, svc api.Service) (*MapNameResolver, error) { + machines, err := cli.ListMachines(ctx) + if err != nil { + return nil, fmt.Errorf("list machines: %w", err) + } + machineNames := make(map[string]string, len(machines)) + for _, m := range machines { + machineNames[m.Machine.Id] = m.Machine.Name + } + containerNames := make(map[string]string, len(svc.Containers)) + for _, c := range svc.Containers { + containerNames[c.Container.ID] = c.Container.NameWithoutSlash() + } + + return NewNameResolver(machineNames, containerNames), nil +}