From 0be42f1f594d48621f9a44c8302ba50c1a698caf Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Mon, 17 Feb 2025 14:29:56 +1000 Subject: [PATCH] feat: print deployment plan for caddy deployment --- cmd/uncloud/caddy/deploy.go | 105 +++++++++++++++++++++++++++++-- internal/cli/client/operation.go | 67 +++++++++++++++++++- 2 files changed, 164 insertions(+), 8 deletions(-) diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index 288a3810..b4928335 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -2,10 +2,16 @@ package caddy import ( "context" + "errors" "fmt" + "github.com/charmbracelet/huh" "github.com/docker/compose/v2/pkg/progress" "github.com/spf13/cobra" + "maps" + "slices" + "strings" "uncloud/internal/cli" + "uncloud/internal/cli/client" ) type deployOptions struct { @@ -38,24 +44,93 @@ func NewDeployCommand() *cobra.Command { } func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { - client, err := uncli.ConnectCluster(ctx, opts.cluster) + clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster) if err != nil { return fmt.Errorf("connect to cluster: %w", err) } - defer client.Close() + defer clusterClient.Close() - d, err := client.NewCaddyDeployment(opts.image) + svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName) + if err != nil { + if !errors.Is(err, client.ErrNotFound) { + return fmt.Errorf("inspect caddy service: %w", err) + } + fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName) + } else { + fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode) + + // Collect unique images of all containers in the running caddy service. + images := make(map[string]struct{}, len(svc.Containers)) + for _, c := range svc.Containers { + images[c.Container.Config.Image] = struct{}{} + } + currentImages := slices.Collect(maps.Keys(images)) + + if len(currentImages) > 1 { + commaSeparatedImages := strings.Join(currentImages, ", ") + fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages) + } else { + fmt.Printf("Current image: %s\n", currentImages[0]) + } + } + + if opts.image != "" { + fmt.Printf("Target image: %s\n", opts.image) + } + + fmt.Println() + fmt.Println("Preparing a deployment plan...") + d, err := clusterClient.NewCaddyDeployment(opts.image) if err != nil { 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) + } + plan, err := d.Plan(ctx) if err != nil { return fmt.Errorf("plan caddy deployment: %w", err) } if len(plan.SequenceOperation.Operations) == 0 { - fmt.Println("caddy service is up to date.") + fmt.Printf("%s service is up to date.\n", client.CaddyServiceName) + return nil + } + + if svc.ID == "" { + fmt.Println("This will run a Caddy container on each machine.") + } else { + fmt.Println("This will perform a rolling update of Caddy containers on each machine.") + } + fmt.Println() + + fmt.Println("Deployment plan:") + fmt.Println(plan.Format(resolver)) + fmt.Println() + + confirmed, err := confirm() + if err != nil { + return fmt.Errorf("confirm deployment: %w", err) + } + if !confirmed { + fmt.Println("Cancelled. No changes were made.") return nil } @@ -64,5 +139,25 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { return fmt.Errorf("deploy caddy: %w", err) } return nil - }, uncli.ProgressOut(), "Deploying service "+d.Spec.Name) + }, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode)) +} + +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 4833b36d..5ac28676 100644 --- a/internal/cli/client/operation.go +++ b/internal/cli/client/operation.go @@ -12,9 +12,16 @@ import ( // Operations can be composed to form complex deployment strategies. type Operation interface { Execute(ctx context.Context, cli *Client) error + // Format returns a human-readable representation of the operation. + Format(resolver NameResolver) string String() string } +type NameResolver interface { + MachineName(machineID string) string + ContainerName(containerID string) string +} + // RunContainerOperation creates and starts a new container on a specific machine. type RunContainerOperation struct { ServiceID string @@ -36,8 +43,14 @@ func (o *RunContainerOperation) Execute(ctx context.Context, cli *Client) error return nil } +func (o *RunContainerOperation) Format(resolver NameResolver) string { + machineName := resolver.MachineName(o.MachineID) + return fmt.Sprintf("%s: Run container [image=%s]", machineName, o.Spec.Container.Image) +} + func (o *RunContainerOperation) String() string { - return fmt.Sprintf("RunContainerOperation[%s, %s, %s]", o.ServiceID, o.Spec.Name, o.MachineID) + return fmt.Sprintf("RunContainerOperation[service_id=%s, image=%s, machine_id=%s]", + o.ServiceID, o.Spec.Container.Image, o.MachineID) } // StopContainerOperation stops a container on a specific machine. @@ -54,8 +67,14 @@ func (o *StopContainerOperation) Execute(ctx context.Context, cli *Client) error return nil } +func (o *StopContainerOperation) Format(resolver NameResolver) string { + machineName := resolver.MachineName(o.MachineID) + return fmt.Sprintf("%s: Stop container [name=%s]", machineName, resolver.ContainerName(o.ContainerID)) +} + func (o *StopContainerOperation) String() string { - return fmt.Sprintf("StopContainerOperation[%s, %s, %s]", o.ServiceID, o.ContainerID, o.MachineID) + return fmt.Sprintf("StopContainerOperation[service_id=%s, container_id=%s, machine_id=%s]", + o.ServiceID, o.ContainerID, o.MachineID) } // RemoveContainerOperation stops and removes a container from a specific machine. @@ -76,8 +95,14 @@ func (o *RemoveContainerOperation) Execute(ctx context.Context, cli *Client) err return nil } +func (o *RemoveContainerOperation) Format(resolver NameResolver) string { + machineName := resolver.MachineName(o.MachineID) + return fmt.Sprintf("%s: Remove container [name=%s]", machineName, resolver.ContainerName(o.ContainerID)) +} + func (o *RemoveContainerOperation) String() string { - return fmt.Sprintf("RemoveContainerOperation[%s, %s, %s]", o.ServiceID, o.ContainerID, o.MachineID) + return fmt.Sprintf("RemoveContainerOperation[service_id=%s, container_id=%s, machine_id=%s]", + o.ServiceID, o.ContainerID, o.MachineID) } // SequenceOperation is a composite operation that executes a sequence of operations in order. @@ -94,6 +119,15 @@ func (o *SequenceOperation) Execute(ctx context.Context, cli *Client) error { return nil } +func (o *SequenceOperation) Format(resolver NameResolver) string { + ops := make([]string, len(o.Operations)) + for i, op := range o.Operations { + ops[i] = "- " + op.Format(resolver) + } + + return strings.Join(ops, "\n") +} + func (o *SequenceOperation) String() string { ops := make([]string, len(o.Operations)) for i, op := range o.Operations { @@ -102,3 +136,30 @@ func (o *SequenceOperation) String() string { return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", ")) } + +// MapNameResolver resolves machine and container IDs to their names using a static map. +type MapNameResolver struct { + machines map[string]string + containers map[string]string +} + +func NewNameResolver(machines, containers map[string]string) *MapNameResolver { + return &MapNameResolver{ + machines: machines, + containers: containers, + } +} + +func (r *MapNameResolver) MachineName(machineID string) string { + if name, ok := r.machines[machineID]; ok { + return name + } + return machineID +} + +func (r *MapNameResolver) ContainerName(containerID string) string { + if name, ok := r.containers[containerID]; ok { + return name + } + return containerID +}