From b4b1232375cf0edd8b56b0026cf1d83b4668eee5 Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Thu, 19 Mar 2026 14:22:01 +1000 Subject: [PATCH] refactor: format deployment plan for caddy (uc caddy deploy) --- cmd/uncloud/caddy/deploy.go | 62 ++++++++++++++++++++++++----- cmd/uncloud/machine/add.go | 15 ++++++-- internal/cli/tui/style.go | 15 +++++++- pkg/client/deploy/deploy.go | 77 +++++++++++++++++++++++++++++-------- 4 files changed, 138 insertions(+), 31 deletions(-) diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index ac0e577e..7d66338e 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -9,6 +9,8 @@ import ( "slices" "strings" + "charm.land/lipgloss/v2" + "github.com/distribution/reference" "github.com/docker/cli/cli/streams" "github.com/docker/compose/v2/pkg/progress" "github.com/psviderski/uncloud/internal/cli" @@ -71,9 +73,11 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { if !errors.Is(err, api.ErrNotFound) { return fmt.Errorf("inspect caddy service: %w", err) } - fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName) + fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) + + tui.Faint.Render(" (not running)")) } else { - fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode) + fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(svc.Name) + + tui.Faint.Render(" ("+svc.Mode+" mode)")) // Collect unique images of all containers in the running caddy service. images := make(map[string]struct{}, len(svc.Containers)) @@ -83,15 +87,22 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { currentImages := slices.Collect(maps.Keys(images)) if len(currentImages) > 1 { - commaSeparatedImages := strings.Join(currentImages, ", ") - fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages) + formattedImages := make([]string, len(currentImages)) + for i, img := range currentImages { + ref, _ := reference.ParseDockerRef(img) + formattedImages[i] = tui.FormatImage(ref, lipgloss.NewStyle()) + } + fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") + + strings.Join(formattedImages, tui.Faint.Render(", "))) } else { - fmt.Printf("Current image: %s\n", currentImages[0]) + ref, _ := reference.ParseDockerRef(currentImages[0]) + fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(ref, lipgloss.NewStyle())) } } if opts.image != "" { - fmt.Printf("Target image: %s\n", opts.image) + ref, _ := reference.ParseDockerRef(opts.image) + fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(ref, tui.Green)) } fmt.Println() @@ -106,7 +117,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { } if opts.image == "" { - fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image) + ref, _ := reference.ParseDockerRef(d.Spec.Container.Image) + fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(ref, + tui.Green) + tui.Faint.Render(" (latest stable)")) } plan, err := d.Plan(ctx) @@ -126,11 +139,40 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { } fmt.Println() - fmt.Println("Deployment plan:") - fmt.Println(plan.Format()) + fmt.Println(tui.Bold.Underline(true).Render("Deployment plan")) fmt.Println() - confirmed, err := tui.Confirm("") + directConn := uncli.DirectConnection() + contextName := uncli.ContextOverrideOrCurrent() + deployTarget := "" + if directConn != "" { + deployTarget = directConn + fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn)) + fmt.Println() + } else if contextName != "" && len(uncli.Config.Contexts) > 1 { + // Only show context if there's more than one to avoid unnecessary clutter. + deployTarget = contextName + fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName)) + fmt.Println() + } + + fmt.Println(plan.Format()) + + summary := plan.FormatSummary() + fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary)))) + fmt.Println(summary) + fmt.Println() + + title := "Proceed with deployment?" + // Include the direct connection or context name in the confirmation prompt to avoid accidentally + // deploying to the wrong cluster. + if deployTarget != "" { + isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title + title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?") + } + + confirmed, err := tui.Confirm(title) if err != nil { return fmt.Errorf("confirm deployment: %w", err) } diff --git a/cmd/uncloud/machine/add.go b/cmd/uncloud/machine/add.go index ffe3ca7b..b58f0e6b 100644 --- a/cmd/uncloud/machine/add.go +++ b/cmd/uncloud/machine/add.go @@ -200,6 +200,8 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, } } + fmt.Println() + fmt.Println("Preparing Caddy deployment...") d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{}) if err != nil { return fmt.Errorf("create caddy deployment: %w", err) @@ -210,15 +212,20 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, return fmt.Errorf("plan caddy deployment: %w", err) } - fmt.Println() if len(plan.Operations) == 0 { fmt.Printf("%s service is up to date.\n", client.CaddyServiceName) } else { - fmt.Println("caddy deployment plan:") - fmt.Println(plan.Format()) + fmt.Println(tui.Bold.Underline(true).Render("Deployment plan")) + fmt.Println() + fmt.Print(plan.Format()) + + summary := plan.FormatSummary() + fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary)))) + fmt.Println(summary) + fmt.Println() if !opts.yes { - confirmed, err := tui.Confirm("") + confirmed, err := tui.Confirm("Proceed with deployment?") if err != nil { return fmt.Errorf("confirm deployment: %w", err) } diff --git a/internal/cli/tui/style.go b/internal/cli/tui/style.go index 0959e38c..75bc1316 100644 --- a/internal/cli/tui/style.go +++ b/internal/cli/tui/style.go @@ -1,6 +1,9 @@ package tui -import "charm.land/lipgloss/v2" +import ( + "charm.land/lipgloss/v2" + "github.com/distribution/reference" +) var ( Faint = lipgloss.NewStyle().Faint(true) @@ -15,3 +18,13 @@ var ( NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("152")) ) + +// FormatImage renders an image reference with the given style, using a faint colon separator for tagged images. +func FormatImage(image reference.Named, style lipgloss.Style) string { + if tagged, ok := image.(reference.NamedTagged); ok { + return style.Render(reference.FamiliarName(image)) + + Faint.Render(":") + + style.Render(tagged.Tag()) + } + return style.Render(reference.FamiliarString(image)) +} diff --git a/pkg/client/deploy/deploy.go b/pkg/client/deploy/deploy.go index b78c31fd..e81c7e58 100644 --- a/pkg/client/deploy/deploy.go +++ b/pkg/client/deploy/deploy.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "charm.land/lipgloss/v2" @@ -163,6 +164,60 @@ func (sp *ServicePlan) Format() string { return out.String() } +// FormatSummary counts operations in the service plan and renders a styled summary line. +func (sp *ServicePlan) FormatSummary() string { + var createCount, startFirstCount, stopFirstCount, removeCount int + machines := make(map[string]struct{}) + + for _, op := range sp.Operations { + switch o := op.(type) { + case *operation.RunContainerOperation: + machines[o.MachineID] = struct{}{} + createCount++ + case *operation.ReplaceContainerOperation: + machines[o.MachineID] = struct{}{} + if o.Order == api.UpdateOrderStopFirst { + stopFirstCount++ + } else { + startFirstCount++ + } + case *operation.RemoveContainerOperation: + machines[o.MachineID] = struct{}{} + removeCount++ + case *operation.StopContainerOperation: + machines[o.MachineID] = struct{}{} + removeCount++ + } + } + + var parts []string + if createCount > 0 { + parts = append(parts, + tui.BoldGreen.Render(strconv.Itoa(createCount))+" "+tui.Green.Render("create")) + } + if startFirstCount > 0 { + parts = append(parts, + tui.BoldGreen.Render(strconv.Itoa(startFirstCount))+" "+tui.Green.Render("replace (start-first)")) + } + if stopFirstCount > 0 { + parts = append(parts, + tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)")) + } + if removeCount > 0 { + parts = append(parts, + tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove")) + } + + machinesWord := "machines" + if len(machines) == 1 { + machinesWord = "machine" + } + parts = append(parts, fmt.Sprintf("across %s %s", tui.Bold.Render(strconv.Itoa(len(machines))), machinesWord)) + + sep := " " + tui.Faint.Render("·") + " " + return strings.Join(parts, sep) +} + // formatImageDiff formats the image for display. If oldImage is empty, it formats newImage as a new (green) value. // Otherwise, it renders the diff between oldImage and newImage. func formatImageDiff(oldImage, newImage string) string { @@ -170,12 +225,12 @@ func formatImageDiff(oldImage, newImage string) string { // Create case: no old image. if oldImage == "" { - return styledImage(newRef, tui.Green) + return tui.FormatImage(newRef, tui.Green) } // Update case: no change. if oldImage == newImage { - return styledImage(newRef, lipgloss.NewStyle()) + return tui.FormatImage(newRef, lipgloss.NewStyle()) } oldRef, _ := reference.ParseDockerRef(oldImage) @@ -184,9 +239,9 @@ func formatImageDiff(oldImage, newImage string) string { _, oldDigested := oldRef.(reference.Digested) _, newDigested := newRef.(reference.Digested) if oldDigested || newDigested { - return styledImage(oldRef, tui.Red) + " " + + return tui.FormatImage(oldRef, tui.Red) + " " + tui.Faint.Render("→") + " " + - styledImage(newRef, tui.Green) + tui.FormatImage(newRef, tui.Green) } // If repos match and both are tagged, show only tag diff. @@ -201,19 +256,9 @@ func formatImageDiff(oldImage, newImage string) string { } // Different repos: full old → new. - return styledImage(oldRef, tui.Red) + " " + + return tui.FormatImage(oldRef, tui.Red) + " " + tui.Faint.Render("→") + " " + - styledImage(newRef, tui.Green) -} - -// styledImage renders a parsed image reference with the given style, using a faint colon separator for tagged images. -func styledImage(image reference.Named, style lipgloss.Style) string { - if tagged, ok := image.(reference.NamedTagged); ok { - return style.Render(reference.FamiliarName(image)) + - tui.Faint.Render(":") + - style.Render(tagged.Tag()) - } - return style.Render(reference.FamiliarString(image)) + tui.FormatImage(newRef, tui.Green) } // NewDeployment creates a new deployment for the given service specification.