refactor: format deployment plan for caddy (uc caddy deploy)

This commit is contained in:
Pasha Sviderski
2026-03-19 14:22:01 +10:00
parent e4cb71b878
commit b4b1232375
4 changed files with 138 additions and 31 deletions
+52 -10
View File
@@ -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)
}
+11 -4
View File
@@ -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)
}
+14 -1
View File
@@ -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))
}
+61 -16
View File
@@ -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.