From 0f4c2110020097d6cf0ecb44981a19e1ff9453cd Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Wed, 18 Mar 2026 21:26:22 +1000 Subject: [PATCH] refactor: format compose deployment plan with style, display target context --- cmd/uncloud/deploy.go | 63 +++++--- pkg/client/compose/deploy.go | 26 ---- pkg/client/compose/plan.go | 131 +++++++++++++++++ pkg/client/deploy/deploy.go | 179 +++++++++++++++++++++++ pkg/client/deploy/operation/container.go | 44 +++++- pkg/client/deploy/operation/sequence.go | 6 +- pkg/client/deploy/operation/volume.go | 7 +- pkg/client/deploy/strategy.go | 4 +- 8 files changed, 399 insertions(+), 61 deletions(-) create mode 100644 pkg/client/compose/plan.go diff --git a/cmd/uncloud/deploy.go b/cmd/uncloud/deploy.go index 2e17157f..edfb44d7 100644 --- a/cmd/uncloud/deploy.go +++ b/cmd/uncloud/deploy.go @@ -4,16 +4,18 @@ import ( "context" "errors" "fmt" - "strings" + "os" "charm.land/lipgloss/v2" composecli "github.com/compose-spec/compose-go/v2/cli" "github.com/docker/compose/v2/pkg/progress" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/deploy" + "github.com/psviderski/uncloud/pkg/client/deploy/operation" "github.com/spf13/cobra" ) @@ -174,7 +176,23 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { return nil } - fmt.Println(lipgloss.NewStyle().Bold(true).Render("Deployment plan")) + fmt.Println(tui.Bold.Underline(true).Render("Deployment plan")) + fmt.Println() + + 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() + } + if err = printPlan(ctx, clusterClient, plan); err != nil { return fmt.Errorf("print deployment plan: %w", err) } @@ -182,12 +200,21 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { // Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes. if !opts.yes { - if !cli.IsStdinTerminal() { + if !tui.IsStdinTerminal() { return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " + "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") } - confirmed, err := cli.Confirm() + 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) } @@ -197,41 +224,31 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { } } + title := "Deploying" + if deployTarget != "" { + title += " to " + tui.NameStyle.Render(deployTarget) + } return progress.RunWithTitle(ctx, func(ctx context.Context) error { if err := plan.Execute(ctx, clusterClient); err != nil { return fmt.Errorf("deploy services: %w", err) } return nil - }, uncli.ProgressOut(), "Deploying services") + }, uncli.ProgressOut(), title) } func printPlan(ctx context.Context, cli *client.Client, plan compose.Plan) error { - for _, op := range plan.Volumes { - fmt.Println("- " + op.Format(nil)) - } - + resolvers := make(map[string]operation.NameResolver) for _, svcPlan := range plan.Services { svc, err := cli.InspectService(ctx, svcPlan.ServiceID) if err != nil && !errors.Is(err, api.ErrNotFound) { return fmt.Errorf("inspect service: %w", err) } - // Initialise a machine and container name resolver to properly format the service plan output. resolver, err := cli.ServiceOperationNameResolver(ctx, svc) if err != nil { - return fmt.Errorf("create machine and container name resolver for service operations: %w", err) + return fmt.Errorf("create resolver for service '%s': %w", svcPlan.ServiceName, err) } - - fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName) - fmt.Println(indent(svcPlan.Format(resolver), " ")) + resolvers[svcPlan.ServiceID] = resolver } - + fmt.Print(plan.Format(resolvers)) return nil } - -func indent(text, prefix string) string { - lines := strings.Split(text, "\n") - for i, line := range lines { - lines[i] = prefix + line - } - return strings.Join(lines, "\n") -} diff --git a/pkg/client/compose/deploy.go b/pkg/client/compose/deploy.go index 274f07de..7d9e8b46 100644 --- a/pkg/client/compose/deploy.go +++ b/pkg/client/compose/deploy.go @@ -31,32 +31,6 @@ type Deployment struct { plan *Plan } -// Plan holds the compose-level deployment plan with typed volume and service operations. -type Plan struct { - Volumes []*operation.CreateVolumeOperation - Services []*deploy.ServicePlan -} - -// IsEmpty returns true if the plan has no volume or service operations. -func (p *Plan) IsEmpty() bool { - return len(p.Volumes) == 0 && len(p.Services) == 0 -} - -// Execute runs all volume operations followed by all service operations. -func (p *Plan) Execute(ctx context.Context, cli operation.Client) error { - for _, op := range p.Volumes { - if err := op.Execute(ctx, cli); err != nil { - return err - } - } - for _, sp := range p.Services { - if err := sp.Execute(ctx, cli); err != nil { - return err - } - } - return nil -} - func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) { return NewDeploymentWithStrategy(ctx, cli, project, nil) } diff --git a/pkg/client/compose/plan.go b/pkg/client/compose/plan.go new file mode 100644 index 00000000..cbedeac4 --- /dev/null +++ b/pkg/client/compose/plan.go @@ -0,0 +1,131 @@ +package compose + +import ( + "context" + "fmt" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/psviderski/uncloud/internal/cli/tui" + "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client/deploy" + "github.com/psviderski/uncloud/pkg/client/deploy/operation" +) + +// Plan holds the compose-level deployment plan with volume and service operations. +type Plan struct { + Volumes []*operation.CreateVolumeOperation + Services []*deploy.ServicePlan +} + +// IsEmpty returns true if the plan has no volume or service operations. +func (p *Plan) IsEmpty() bool { + return len(p.Volumes) == 0 && len(p.Services) == 0 +} + +// Format renders the entire deployment plan as a styled tree with a summary footer. +func (p *Plan) Format(resolvers map[string]operation.NameResolver) string { + var out strings.Builder + + // Format volume operations. + for _, op := range p.Volumes { + out.WriteString(op.Format(nil)) + out.WriteString("\n") + } + if len(p.Volumes) > 0 { + out.WriteString("\n") + } + + // Format service plans. + for _, svcPlan := range p.Services { + resolver := resolvers[svcPlan.ServiceID] + out.WriteString(svcPlan.Format(resolver)) + out.WriteString("\n\n") + } + + // Format summary footer. + summary := p.formatSummary() + out.WriteString(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary)))) + out.WriteString("\n") + out.WriteString(summary) + out.WriteString("\n") + + return out.String() +} + +// formatSummary counts all operations across the plan and renders the summary footer. +func (p *Plan) formatSummary() string { + var createCount, startFirstCount, stopFirstCount, removeCount int + machines := make(map[string]struct{}) + + for _, op := range p.Volumes { + machines[op.MachineID] = struct{}{} + createCount++ + } + + for _, svcPlan := range p.Services { + for _, op := range svcPlan.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) +} + +// Execute runs all volume operations followed by all service operations. +func (p *Plan) Execute(ctx context.Context, cli operation.Client) error { + for _, op := range p.Volumes { + if err := op.Execute(ctx, cli); err != nil { + return err + } + } + for _, sp := range p.Services { + if err := sp.Execute(ctx, cli); err != nil { + return err + } + } + return nil +} diff --git a/pkg/client/deploy/deploy.go b/pkg/client/deploy/deploy.go index e70f633f..28f1f34d 100644 --- a/pkg/client/deploy/deploy.go +++ b/pkg/client/deploy/deploy.go @@ -4,7 +4,12 @@ import ( "context" "errors" "fmt" + "strings" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/distribution/reference" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client/deploy/operation" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler" @@ -34,9 +39,183 @@ type Deployment struct { type ServicePlan struct { ServiceID string ServiceName string + // Spec is the desired service spec being deployed. + Spec api.ServiceSpec operation.SequenceOperation } +// Format renders the service plan as a styled block with a spec diff and nested container operations. +func (sp *ServicePlan) Format(resolver operation.NameResolver) string { + // Determine service-level operation type and extract the old spec from container operations. + // Assume replace operations precede remove operations (rolling strategy) so the first replace operation + // (if exists) determines the old spec for the diff. Otherwise, fallback to the first remove operation. + var hasRun, hasReplace, hasRemove bool + var oldSpec *api.ServiceSpec + for _, op := range sp.Operations { + switch o := op.(type) { + case *operation.RunContainerOperation: + hasRun = true + case *operation.ReplaceContainerOperation: + hasReplace = true + if oldSpec == nil { + oldSpec = &o.OldContainer.ServiceSpec + } + case *operation.RemoveContainerOperation: + hasRemove = true + if oldSpec == nil { + oldSpec = &o.Container.ServiceSpec + } + } + } + + // Service line modifier and verb. + var modifier, verb string + switch { + case hasRun && !hasReplace && !hasRemove: + modifier = tui.BoldGreen.Render("+") + verb = "create" + // TODO: when service removal via a deployment is supported, handle "remove" verb here as well. + default: + modifier = tui.BoldYellow.Render("~") + verb = "update" + } + + var out strings.Builder + line := modifier + " " + verb + " service " + tui.NameStyle.Render(sp.ServiceName) + if sp.Spec.Mode == api.ServiceModeGlobal { + line += " " + tui.Faint.Render("(global)") + } + out.WriteString(line) + out.WriteString("\n") + + // Build spec diff table: columns are [modifier, attribute, value or change]. + // TODO: print diff for all changed attributes, not just image and replicas. + // Consider reusing the logic in EvalContainerSpecChange to return a structured diff. + specTable := table.New(). + Border(lipgloss.Border{}). + BorderTop(false).BorderBottom(false). + BorderLeft(false).BorderRight(false). + BorderHeader(false).BorderColumn(false). + StyleFunc(func(row, col int) lipgloss.Style { + switch col { + case 0: // Modifier column. + return tui.Yellow.Width(2) + case 1: // Attribute column. + return tui.Faint.PaddingRight(1) + default: + return lipgloss.NewStyle() + } + }) + + // Image row. + if oldSpec == nil { + specTable.Row("", "image:", formatImageDiff("", sp.Spec.Container.Image)) + } else { + mod := "" + if oldSpec.Container.Image != sp.Spec.Container.Image { + mod = "~" + } + specTable.Row(mod, "image:", formatImageDiff(oldSpec.Container.Image, sp.Spec.Container.Image)) + } + + // Replicas row for replicated services. + if sp.Spec.Mode == api.ServiceModeReplicated { + replicasStr := fmt.Sprintf("%d", sp.Spec.Replicas) + if oldSpec == nil { + specTable.Row("", "replicas:", tui.Green.Render(replicasStr)) + } else if sp.Spec.Replicas > 1 || hasRun || hasRemove { + mod := "" + if hasRun || hasRemove { + mod = "~" + replicasStr = tui.Green.Render(replicasStr) + } + specTable.Row(mod, "replicas:", replicasStr) + } + } + + // Stack " │ " tree prefixes vertically, then join horizontally with the table. + tableStr := specTable.String() + treePrefix := tui.Faint.Render(" │ ") + treeColRows := make([]string, specTable.GetData().Rows()) + for i := range treeColRows { + treeColRows[i] = treePrefix + } + treeCol := lipgloss.JoinVertical(lipgloss.Left, treeColRows...) + + out.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, treeCol, tableStr)) + out.WriteString("\n") + + // Blank separator line before container operations. + out.WriteString(tui.Faint.Render(" │")) + out.WriteString("\n") + + // Format each container operation. + opsCount := len(sp.Operations) + for i, op := range sp.Operations { + connector := tui.Faint.Render(" ├──") + if i == opsCount-1 { + connector = tui.Faint.Render(" ╰──") + } + out.WriteString(connector + " " + op.Format(resolver)) + out.WriteString("\n") + } + + return out.String() +} + +// 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 { + newRef, _ := reference.ParseDockerRef(newImage) // ignore error since the image was already validated + + // Create case: no old image. + if oldImage == "" { + return styledImage(newRef, tui.Green) + } + + // Update case: no change. + if oldImage == newImage { + return styledImage(newRef, lipgloss.NewStyle()) + } + + oldRef, _ := reference.ParseDockerRef(oldImage) + + // If either uses a digest, show full old → new. + _, oldDigested := oldRef.(reference.Digested) + _, newDigested := newRef.(reference.Digested) + if oldDigested || newDigested { + return styledImage(oldRef, tui.Red) + " " + + tui.Faint.Render("→") + " " + + styledImage(newRef, tui.Green) + } + + // If repos match and both are tagged, show only tag diff. + oldTagged, oldOk := oldRef.(reference.NamedTagged) + newTagged, newOk := newRef.(reference.NamedTagged) + if oldOk && newOk && reference.FamiliarName(oldRef) == reference.FamiliarName(newRef) { + return reference.FamiliarName(newRef) + + tui.Faint.Render(":") + + tui.Red.Render(oldTagged.Tag()) + " " + + tui.Faint.Render("→") + " " + + tui.Green.Render(newTagged.Tag()) + } + + // Different repos: full old → new. + return styledImage(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)) +} + // NewDeployment creates a new deployment for the given service specification. // If strategy is nil, a default RollingStrategy will be used. func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deployment { diff --git a/pkg/client/deploy/operation/container.go b/pkg/client/deploy/operation/container.go index f7e1c6a3..afd0717c 100644 --- a/pkg/client/deploy/operation/container.go +++ b/pkg/client/deploy/operation/container.go @@ -8,6 +8,7 @@ import ( "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/container" "github.com/docker/docker/pkg/stringid" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" ) @@ -44,7 +45,11 @@ func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error { 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) + return tui.BoldGreen.Render("+") + " " + + tui.Faint.Render("run container") + " " + + o.Spec.Name + " " + + tui.Faint.Render("on") + " " + + machineName } func (o *RunContainerOperation) String() string { @@ -69,8 +74,14 @@ func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error func (o *StopContainerOperation) Format(resolver NameResolver) string { machineName := resolver.MachineName(o.MachineID) - return fmt.Sprintf("%s: Stop container [id=%s name=%s]", machineName, - o.ContainerID[:12], resolver.ContainerName(o.ContainerID)) + // TODO: pass service name to format the display name consistently with other operations. + displayName := stringid.TruncateID(o.ContainerID) + + return tui.BoldRed.Render("-") + " " + + tui.Faint.Render("stop container") + " " + + displayName + " " + + tui.Faint.Render("on") + " " + + machineName } func (o *StopContainerOperation) String() string { @@ -103,8 +114,13 @@ func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) erro func (o *RemoveContainerOperation) Format(resolver NameResolver) string { machineName := resolver.MachineName(o.MachineID) - return fmt.Sprintf("%s: Remove container [id=%s image=%s]", - machineName, o.Container.ShortID(), o.Container.Config.Image) + displayName := o.Container.ServiceSpec.Name + tui.Faint.Render("/") + o.Container.ShortID() + + return tui.BoldRed.Render("-") + " " + + tui.Faint.Render("remove container") + " " + + displayName + " " + + tui.Faint.Render("on") + " " + + machineName } func (o *RemoveContainerOperation) String() string { @@ -208,8 +224,22 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err } func (o *ReplaceContainerOperation) Format(resolver NameResolver) string { - return fmt.Sprintf("%s: Replace container [id=%s image=%s order=%s]", - resolver.MachineName(o.MachineID), o.OldContainer.ShortID(), o.Spec.Container.Image, o.Order) + machineName := resolver.MachineName(o.MachineID) + displayName := o.Spec.Name + tui.Faint.Render("/") + o.OldContainer.ShortID() + + if o.Order == api.UpdateOrderStopFirst { + return tui.BoldYellow.Render("-") + tui.Yellow.Render("/") + tui.BoldYellow.Render("+") + " " + + tui.Faint.Render("replace container") + " " + + displayName + " " + + tui.Faint.Render("on") + " " + + machineName + " " + + tui.Yellow.Render("(stop-first)") + } + return tui.BoldGreen.Render("+") + tui.Green.Render("/") + tui.BoldGreen.Render("-") + " " + + tui.Faint.Render("replace container") + " " + + displayName + " " + + tui.Faint.Render("on") + " " + + machineName } func (o *ReplaceContainerOperation) String() string { diff --git a/pkg/client/deploy/operation/sequence.go b/pkg/client/deploy/operation/sequence.go index 491f6435..16ed8fea 100644 --- a/pkg/client/deploy/operation/sequence.go +++ b/pkg/client/deploy/operation/sequence.go @@ -21,12 +21,12 @@ func (o *SequenceOperation) Execute(ctx context.Context, cli Client) error { } func (o *SequenceOperation) Format(resolver NameResolver) string { - ops := make([]string, len(o.Operations)) + lines := make([]string, len(o.Operations)) for i, op := range o.Operations { - ops[i] = "- " + op.Format(resolver) + lines[i] = op.Format(resolver) } - return strings.Join(ops, "\n") + return strings.Join(lines, "\n") } func (o *SequenceOperation) String() string { diff --git a/pkg/client/deploy/operation/volume.go b/pkg/client/deploy/operation/volume.go index 8e29cae3..ad8b6dfe 100644 --- a/pkg/client/deploy/operation/volume.go +++ b/pkg/client/deploy/operation/volume.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/docker/docker/api/types/volume" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" ) @@ -40,7 +41,11 @@ func (o *CreateVolumeOperation) Execute(ctx context.Context, cli Client) error { } func (o *CreateVolumeOperation) Format(_ NameResolver) string { - return fmt.Sprintf("%s: Create volume [name=%s]", o.MachineName, o.VolumeSpec.DockerVolumeName()) + return fmt.Sprintf("%s create volume %s %s %s", + tui.BoldGreen.Render("+"), + tui.NameStyle.Render(o.VolumeSpec.DockerVolumeName()), + tui.Faint.Render("on"), + o.MachineName) } func (o *CreateVolumeOperation) String() string { diff --git a/pkg/client/deploy/strategy.go b/pkg/client/deploy/strategy.go index c7ec6d01..f7d52334 100644 --- a/pkg/client/deploy/strategy.go +++ b/pkg/client/deploy/strategy.go @@ -405,7 +405,9 @@ func determineUpdateOrder(oldContainer api.ServiceContainer, spec api.ServiceSpe // newEmptyServicePlan creates a new empty plan for a service deployment with initialised service ID and name. func newEmptyServicePlan(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) { - var plan ServicePlan + plan := ServicePlan{ + Spec: spec, + } // Generate a new service ID for the initial service deployment if it doesn't exist yet. if svc != nil {