fix: plan formatting for service scaling (update verb), fix image and replicas diff

This commit is contained in:
Pasha Sviderski
2026-03-19 15:29:21 +10:00
parent f5a77b82b2
commit 3f95583dd4
4 changed files with 35 additions and 29 deletions
+4 -9
View File
@@ -10,7 +10,6 @@ import (
"strings" "strings"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/distribution/reference"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -89,20 +88,17 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
if len(currentImages) > 1 { if len(currentImages) > 1 {
formattedImages := make([]string, len(currentImages)) formattedImages := make([]string, len(currentImages))
for i, img := range currentImages { for i, img := range currentImages {
ref, _ := reference.ParseDockerRef(img) formattedImages[i] = tui.FormatImage(img, lipgloss.NewStyle())
formattedImages[i] = tui.FormatImage(ref, lipgloss.NewStyle())
} }
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") + fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
strings.Join(formattedImages, tui.Faint.Render(", "))) strings.Join(formattedImages, tui.Faint.Render(", ")))
} else { } else {
ref, _ := reference.ParseDockerRef(currentImages[0]) fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(ref, lipgloss.NewStyle()))
} }
} }
if opts.image != "" { if opts.image != "" {
ref, _ := reference.ParseDockerRef(opts.image) fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(ref, tui.Green))
} }
fmt.Println() fmt.Println()
@@ -117,8 +113,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
if opts.image == "" { if opts.image == "" {
ref, _ := reference.ParseDockerRef(d.Spec.Container.Image) fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(ref,
tui.Green) + tui.Faint.Render(" (latest stable)")) tui.Green) + tui.Faint.Render(" (latest stable)"))
} }
+10 -4
View File
@@ -20,11 +20,17 @@ var (
) )
// FormatImage renders an image reference with the given style, using a faint colon separator for tagged images. // 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 { // Returns the styled original string if parsing fails.
if tagged, ok := image.(reference.NamedTagged); ok { func FormatImage(image string, style lipgloss.Style) string {
return style.Render(reference.FamiliarName(image)) + ref, err := reference.ParseDockerRef(image)
if err != nil {
return style.Render(image)
}
if tagged, ok := ref.(reference.NamedTagged); ok {
return style.Render(reference.FamiliarName(ref)) +
Faint.Render(":") + Faint.Render(":") +
style.Render(tagged.Tag()) style.Render(tagged.Tag())
} }
return style.Render(reference.FamiliarString(image)) return style.Render(reference.FamiliarString(ref))
} }
+20 -16
View File
@@ -40,6 +40,8 @@ type Deployment struct {
type ServicePlan struct { type ServicePlan struct {
ServiceID string ServiceID string
ServiceName string ServiceName string
// IsNewService indicates this plan creates a new service (first deployment) rather than updating an existing one.
IsNewService bool
// Spec is the desired service spec being deployed. // Spec is the desired service spec being deployed.
Spec api.ServiceSpec Spec api.ServiceSpec
operation.SequenceOperation operation.SequenceOperation
@@ -50,14 +52,13 @@ func (sp *ServicePlan) Format() string {
// Determine service-level operation type and extract the old spec from container operations. // 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 // 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. // (if exists) determines the old spec for the diff. Otherwise, fallback to the first remove operation.
var hasRun, hasReplace, hasRemove bool var hasRun, hasRemove bool
var oldSpec *api.ServiceSpec var oldSpec *api.ServiceSpec
for _, op := range sp.Operations { for _, op := range sp.Operations {
switch o := op.(type) { switch o := op.(type) {
case *operation.RunContainerOperation: case *operation.RunContainerOperation:
hasRun = true hasRun = true
case *operation.ReplaceContainerOperation: case *operation.ReplaceContainerOperation:
hasReplace = true
if oldSpec == nil { if oldSpec == nil {
oldSpec = &o.OldContainer.ServiceSpec oldSpec = &o.OldContainer.ServiceSpec
} }
@@ -72,7 +73,7 @@ func (sp *ServicePlan) Format() string {
// Service line modifier and verb. // Service line modifier and verb.
var modifier, verb string var modifier, verb string
switch { switch {
case hasRun && !hasReplace && !hasRemove: case sp.IsNewService:
modifier = tui.BoldGreen.Render("+") modifier = tui.BoldGreen.Render("+")
verb = "create" verb = "create"
// TODO: when service removal via a deployment is supported, handle "remove" verb here as well. // TODO: when service removal via a deployment is supported, handle "remove" verb here as well.
@@ -110,7 +111,11 @@ func (sp *ServicePlan) Format() string {
// Image row. // Image row.
if oldSpec == nil { if oldSpec == nil {
specTable.Row("", "image:", formatImageDiff("", sp.Spec.Container.Image)) if sp.IsNewService {
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, tui.Green))
} else {
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, lipgloss.NewStyle()))
}
} else { } else {
mod := "" mod := ""
if oldSpec.Container.Image != sp.Spec.Container.Image { if oldSpec.Container.Image != sp.Spec.Container.Image {
@@ -122,8 +127,10 @@ func (sp *ServicePlan) Format() string {
// Replicas row for replicated services. // Replicas row for replicated services.
if sp.Spec.Mode == api.ServiceModeReplicated { if sp.Spec.Mode == api.ServiceModeReplicated {
replicasStr := fmt.Sprintf("%d", sp.Spec.Replicas) replicasStr := fmt.Sprintf("%d", sp.Spec.Replicas)
if oldSpec == nil { if sp.IsNewService {
specTable.Row("", "replicas:", tui.Green.Render(replicasStr)) if sp.Spec.Replicas > 1 {
specTable.Row("", "replicas:", tui.Green.Render(replicasStr))
}
} else if sp.Spec.Replicas > 1 || hasRun || hasRemove { } else if sp.Spec.Replicas > 1 || hasRun || hasRemove {
mod := "" mod := ""
if hasRun || hasRemove { if hasRun || hasRemove {
@@ -221,27 +228,24 @@ func (sp *ServicePlan) FormatSummary() string {
// formatImageDiff formats the image for display. If oldImage is empty, it formats newImage as a new (green) value. // 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. // Otherwise, it renders the diff between oldImage and newImage.
func formatImageDiff(oldImage, newImage string) string { 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 == "" { if oldImage == "" {
return tui.FormatImage(newRef, tui.Green) return tui.FormatImage(newImage, tui.Green)
} }
// Update case: no change.
if oldImage == newImage { if oldImage == newImage {
return tui.FormatImage(newRef, lipgloss.NewStyle()) return tui.FormatImage(newImage, lipgloss.NewStyle())
} }
oldRef, _ := reference.ParseDockerRef(oldImage) oldRef, _ := reference.ParseDockerRef(oldImage)
newRef, _ := reference.ParseDockerRef(newImage)
// If either uses a digest, show full old → new. // If either uses a digest, show full old → new.
_, oldDigested := oldRef.(reference.Digested) _, oldDigested := oldRef.(reference.Digested)
_, newDigested := newRef.(reference.Digested) _, newDigested := newRef.(reference.Digested)
if oldDigested || newDigested { if oldDigested || newDigested {
return tui.FormatImage(oldRef, tui.Red) + " " + return tui.FormatImage(oldImage, tui.Red) + " " +
tui.Faint.Render("→") + " " + tui.Faint.Render("→") + " " +
tui.FormatImage(newRef, tui.Green) tui.FormatImage(newImage, tui.Green)
} }
// If repos match and both are tagged, show only tag diff. // If repos match and both are tagged, show only tag diff.
@@ -256,9 +260,9 @@ func formatImageDiff(oldImage, newImage string) string {
} }
// Different repos: full old → new. // Different repos: full old → new.
return tui.FormatImage(oldRef, tui.Red) + " " + return tui.FormatImage(oldImage, tui.Red) + " " +
tui.Faint.Render("→") + " " + tui.Faint.Render("→") + " " +
tui.FormatImage(newRef, tui.Green) tui.FormatImage(newImage, tui.Green)
} }
// NewDeployment creates a new deployment for the given service specification. // NewDeployment creates a new deployment for the given service specification.
+1
View File
@@ -446,6 +446,7 @@ func newEmptyServicePlan(svc *api.Service, spec api.ServiceSpec) (ServicePlan, e
plan.ServiceID = svc.ID plan.ServiceID = svc.ID
plan.ServiceName = svc.Name plan.ServiceName = svc.Name
} else { } else {
plan.IsNewService = true
var err error var err error
plan.ServiceID, err = secret.NewID() plan.ServiceID, err = secret.NewID()
if err != nil { if err != nil {