Compare commits

...
3 Commits
8 changed files with 201 additions and 24 deletions
+8 -17
View File
@@ -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()
+8 -1
View File
@@ -12,6 +12,7 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
"net/netip"
"time"
"uncloud/cmd/uncloud/caddy"
"uncloud/internal/cli"
"uncloud/internal/cli/client"
"uncloud/internal/cli/config"
@@ -138,12 +139,18 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
return fmt.Errorf("create caddy deployment: %w", err)
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = d.Run(ctx); err != nil {
return fmt.Errorf("deploy caddy: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
if err != nil {
return err
}
fmt.Println()
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
}
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
+1
View File
@@ -51,6 +51,7 @@ func main() {
service.NewListCommand(),
service.NewRmCommand(),
service.NewRunCommand(),
service.NewScaleCommand(),
)
cobra.CheckErr(cmd.Execute())
}
+1
View File
@@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command {
NewListCommand(),
NewRmCommand(),
NewRunCommand(),
NewScaleCommand(),
)
return cmd
}
+161
View File
@@ -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
}
-1
View File
@@ -31,7 +31,6 @@ func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Depl
image = reference.FamiliarString(latest)
}
// TODO: set restart policy to always. https://github.com/psviderski/uncloud/issues/26
spec := api.ServiceSpec{
Container: api.ContainerSpec{
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
+19
View File
@@ -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
}
+3 -5
View File
@@ -541,10 +541,8 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
}
publicIP, pubIPErr := network.GetPublicIP()
// Ignore the error if failed to get the public IP using API services.
if pubIPErr == nil {
if !slices.Contains(ips, publicIP) {
ips = append(ips, publicIP)
}
if pubIPErr == nil && !slices.Contains(ips, publicIP) {
ips = append(ips, publicIP)
}
endpoints := make([]*pb.IPPort, len(ips))
for i, addr := range ips {
@@ -685,7 +683,7 @@ func (m *Machine) Token(_ context.Context, _ *emptypb.Empty) (*pb.TokenResponse,
}
publicIP, err := network.GetPublicIP()
// Ignore the error if failed to get the public IP using API services.
if err == nil {
if err == nil && !slices.Contains(ips, publicIP) {
ips = append(ips, publicIP)
}
endpoints := make([]netip.AddrPort, len(ips))