diff --git a/go.mod b/go.mod index b278c90e..ef2175f8 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/caddyserver/caddy/v2 v2.8.4 github.com/cenkalti/backoff/v4 v4.3.0 github.com/charmbracelet/colorprofile v0.4.2 + github.com/charmbracelet/x/ansi v0.11.6 github.com/compose-spec/compose-go/v2 v2.9.0 github.com/containerd/errdefs v1.0.0 github.com/containerd/platforms v1.0.0-rc.1 @@ -111,7 +112,6 @@ require ( github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/internal/cli/progress/event.go b/internal/cli/progress/event.go index fb584fb6..aeb0de37 100644 --- a/internal/cli/progress/event.go +++ b/internal/cli/progress/event.go @@ -1,12 +1,49 @@ package progress import ( - "fmt" + "context" + "github.com/docker/docker/pkg/stringid" "github.com/psviderski/uncloud/internal/cli/tui" ) -// PreDeployHookEventID returns a progress event identifier for pre-deploy hook operations. +type eventIDKey struct{} + +// WithEventID returns a context that overrides the default event ID used by client methods. +func WithEventID(ctx context.Context, eventID string) context.Context { + return context.WithValue(ctx, eventIDKey{}, eventID) +} + +// ContainerEventID returns a progress event ID for operations on existing containers using the canonical +// service_name/short_id format. Allows to override it using WithEventID. +func ContainerEventID(ctx context.Context, serviceName, containerID, machineName string) string { + if id, ok := ctx.Value(eventIDKey{}).(string); ok && id != "" { + return id + } + return tui.Faint.Render("Container ") + + serviceName + tui.Faint.Render("/") + stringid.TruncateID(containerID) + + tui.Faint.Render(" on ") + machineName +} + +// NewContainerEventID returns a progress event ID for new container creation where the Docker container ID +// is not yet known. Allows to override it using WithEventID. +func NewContainerEventID(ctx context.Context, containerName, machineName string) string { + if id, ok := ctx.Value(eventIDKey{}).(string); ok && id != "" { + return id + } + return tui.Faint.Render("Container ") + containerName + + tui.Faint.Render(" on ") + machineName +} + +// PreDeployHookEventID returns a progress event ID for pre-deploy hook operations. func PreDeployHookEventID(serviceName, machineName string) string { - return fmt.Sprintf("Pre-deploy hook for %s on %s", tui.NameStyle.Render(serviceName), tui.Bold.Render(machineName)) + return tui.Faint.Render("Pre-deploy hook ") + serviceName + + tui.Faint.Render(" on ") + machineName +} + +// OldPreDeployHookEventID returns a progress event ID for old pre-deploy hook container cleanup. +func OldPreDeployHookEventID(serviceName, containerID, machineName string) string { + return tui.Faint.Render("Old pre-deploy hook ") + + serviceName + tui.Faint.Render("/") + stringid.TruncateID(containerID) + + tui.Faint.Render(" on ") + machineName } diff --git a/internal/cli/tui/prompt.go b/internal/cli/tui/prompt.go index 9d741595..865bcc62 100644 --- a/internal/cli/tui/prompt.go +++ b/internal/cli/tui/prompt.go @@ -51,3 +51,13 @@ func IsStdinTerminal() bool { func IsStdoutTerminal() bool { return term.IsTerminal(int(os.Stdout.Fd())) } + +// TerminalWidth returns the width of the terminal. +// Returns 0 if stdout is not a terminal or the width cannot be determined. +func TerminalWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return 0 + } + return width +} diff --git a/pkg/api/client.go b/pkg/api/client.go index 70d7d1a5..2980119f 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -20,10 +20,10 @@ type Client interface { type ContainerClient interface { CreateContainer( ctx context.Context, serviceID string, spec ServiceSpec, machineID string, - ) (container.CreateResponse, error) + ) (CreateContainerResponse, error) CreatePreDeployHookContainer( ctx context.Context, serviceID string, spec ServiceSpec, machineID string, - ) (container.CreateResponse, error) + ) (CreateContainerResponse, error) ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error) InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error) StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error diff --git a/pkg/api/container.go b/pkg/api/container.go index 2e15804e..b8cc136d 100644 --- a/pkg/api/container.go +++ b/pkg/api/container.go @@ -164,6 +164,13 @@ func (c *Container) UnmarshalJSON(data []byte) error { return nil } +// CreateContainerResponse wraps a container creation response with the container name assigned during creation. +type CreateContainerResponse struct { + container.CreateResponse + // Name is the container name assigned during creation. + Name string +} + type ServiceContainer struct { Container ServiceSpec ServiceSpec diff --git a/pkg/client/container.go b/pkg/client/container.go index feb9f283..49892886 100644 --- a/pkg/client/container.go +++ b/pkg/client/container.go @@ -2,13 +2,12 @@ package client import ( "context" + "encoding/json" "errors" "fmt" "strings" "time" - "encoding/json" - "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/container" "github.com/docker/docker/pkg/jsonmessage" @@ -28,7 +27,7 @@ import ( // CreateContainer creates a new container for the given service on the specified machine. func (cli *Client) CreateContainer( ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string, -) (container.CreateResponse, error) { +) (api.CreateContainerResponse, error) { return cli.createServiceContainerWithPull(ctx, serviceID, spec, machineID, pb.CreateServiceContainerRequest_SERVICE) } @@ -36,7 +35,7 @@ func (cli *Client) CreateContainer( // on the specified machine. func (cli *Client) CreatePreDeployHookContainer( ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string, -) (container.CreateResponse, error) { +) (api.CreateContainerResponse, error) { return cli.createServiceContainerWithPull( ctx, serviceID, spec, machineID, pb.CreateServiceContainerRequest_PRE_DEPLOY) } @@ -49,8 +48,8 @@ func (cli *Client) createServiceContainerWithPull( spec api.ServiceSpec, machineID string, containerType pb.CreateServiceContainerRequest_ContainerType, -) (container.CreateResponse, error) { - var resp container.CreateResponse +) (api.CreateContainerResponse, error) { + var resp api.CreateContainerResponse spec = spec.SetDefaults() if err := spec.Validate(); err != nil { @@ -69,16 +68,16 @@ func (cli *Client) createServiceContainerWithPull( } containerName := fmt.Sprintf("%s-%s", spec.Name, suffix) - eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Machine.Name) if containerType == pb.CreateServiceContainerRequest_PRE_DEPLOY { containerName = fmt.Sprintf("%s-%s-%s", spec.Name, api.LabelHookPreDeploy, suffix) - eventID = cliprogress.PreDeployHookEventID(spec.Name, machine.Machine.Name) } + resp.Name = containerName // Proxy Docker gRPC requests to the selected machine. ctx = proxyToMachine(ctx, machine.Machine) pw := progress.ContextWriter(ctx) + eventID := cliprogress.NewContainerEventID(ctx, containerName, machine.Machine.Name) pw.Event(progress.CreatingEvent(eventID)) if spec.Container.PullPolicy == api.PullPolicyAlways { @@ -122,7 +121,7 @@ func (cli *Client) createServiceContainerWithPull( } } - if err = json.Unmarshal(grpcResp.Response, &resp); err != nil { + if err = json.Unmarshal(grpcResp.Response, &resp.CreateResponse); err != nil { return resp, fmt.Errorf("unmarshal gRPC response: %w", err) } pw.Event(progress.CreatedEvent(eventID)) @@ -293,7 +292,7 @@ func (cli *Client) StartContainer(ctx context.Context, serviceNameOrID, containe ctx = proxyToMachine(ctx, machine.Machine) pw := progress.ContextWriter(ctx) - eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name) + eventID := cliprogress.ContainerEventID(ctx, ctr.Container.ServiceSpec.Name, ctr.Container.ID, machine.Machine.Name) pw.Event(progress.StartingEvent(eventID)) if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil { @@ -305,6 +304,8 @@ func (cli *Client) StartContainer(ctx context.Context, serviceNameOrID, containe } // StopContainer stops the specified container within the service. +// +//nolint:dupl // Structurally similar to RemoveContainer but performs a different operation. func (cli *Client) StopContainer( ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions, ) error { @@ -320,7 +321,7 @@ func (cli *Client) StopContainer( ctx = proxyToMachine(ctx, machine.Machine) pw := progress.ContextWriter(ctx) - eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name) + eventID := cliprogress.ContainerEventID(ctx, ctr.Container.ServiceSpec.Name, ctr.Container.ID, machine.Machine.Name) pw.Event(progress.StoppingEvent(eventID)) if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil { @@ -332,6 +333,8 @@ func (cli *Client) StopContainer( } // RemoveContainer removes the specified container within the service. +// +//nolint:dupl // Structurally similar to StopContainer but performs a different operation. func (cli *Client) RemoveContainer( ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions, ) error { @@ -347,7 +350,7 @@ func (cli *Client) RemoveContainer( ctx = proxyToMachine(ctx, machine.Machine) pw := progress.ContextWriter(ctx) - eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name) + eventID := cliprogress.ContainerEventID(ctx, ctr.Container.ServiceSpec.Name, ctr.Container.ID, machine.Machine.Name) pw.Event(progress.RemovingEvent(eventID)) if err = cli.Docker.RemoveServiceContainer(ctx, ctr.Container.ID, opts); err != nil { @@ -427,7 +430,7 @@ func (cli *Client) WaitContainerHealthy( } pw := progress.ContextWriter(ctx) - eventID := fmt.Sprintf("Container %s on %s", mc.Container.Name, machine.Machine.Name) + eventID := cliprogress.ContainerEventID(ctx, mc.Container.ServiceSpec.Name, mc.Container.ID, machine.Machine.Name) var monitor time.Duration if opts.MonitorPeriod == nil { diff --git a/pkg/client/deploy/operation/container.go b/pkg/client/deploy/operation/container.go index 6a6d3662..8b81efe5 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" + cliprogress "github.com/psviderski/uncloud/internal/cli/progress" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" ) @@ -28,6 +29,10 @@ func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error { if err != nil { return fmt.Errorf("create container: %w", err) } + // Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation. + // TODO: This is a hack to work around the limitations of the compose progress library. + // We likely need to fork or create our own to decouple event IDs from presentation layer. + ctx = cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName)) if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil { return fmt.Errorf("start container: %w", err) } @@ -171,13 +176,15 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err if err != nil { return fmt.Errorf("create new container: %w", err) } - if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil { + // Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation. + newCtx := cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName)) + if err = cli.StartContainer(newCtx, o.ServiceID, resp.ID); err != nil { return fmt.Errorf("start new container: %w", err) } if !o.SkipHealthMonitor { opts := api.WaitContainerHealthyOptions{MonitorPeriod: o.Spec.UpdateConfig.MonitorPeriod} - if err = cli.WaitContainerHealthy(ctx, o.ServiceID, resp.ID, opts); err != nil { + if err = cli.WaitContainerHealthy(newCtx, o.ServiceID, resp.ID, opts); err != nil { // New container failed to become healthy. Stop it and roll back to the previous container. // Don't remove the new stopped container to allow users to inspect logs and state. // TODO: collect logs from the new container and include in the error message to speed up debugging. diff --git a/pkg/client/deploy/operation/predeploy.go b/pkg/client/deploy/operation/predeploy.go index 82d8f1a2..803725fa 100644 --- a/pkg/client/deploy/operation/predeploy.go +++ b/pkg/client/deploy/operation/predeploy.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/containerd/errdefs" "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/container" @@ -65,13 +67,18 @@ type RunPreDeployOperation struct { func (o *RunPreDeployOperation) Execute(ctx context.Context, cli Client) error { // Remove old pre-deploy containers. for _, id := range o.OldContainerIDs { - _ = cli.StopContainer(ctx, o.ServiceID, id, container.StopOptions{}) - err := cli.RemoveContainer(ctx, o.ServiceID, id, container.RemoveOptions{RemoveVolumes: true}) + oldCtx := cliprogress.WithEventID(ctx, cliprogress.OldPreDeployHookEventID(o.Spec.Name, id, o.MachineName)) + _ = cli.StopContainer(oldCtx, o.ServiceID, id, container.StopOptions{}) + err := cli.RemoveContainer(oldCtx, o.ServiceID, id, container.RemoveOptions{RemoveVolumes: true}) if err != nil && !errdefs.IsNotFound(err) { return fmt.Errorf("remove old pre-deploy hook container '%s': %w", id, err) } } + // Set the event ID override so all downstream client methods (create, start, stop) use the same + // pre-deploy hook progress event instead of the generic container one. + ctx = cliprogress.WithEventID(ctx, cliprogress.PreDeployHookEventID(o.Spec.Name, o.MachineName)) + resp, err := cli.CreatePreDeployHookContainer(ctx, o.ServiceID, o.Spec, o.MachineID) if err != nil { return fmt.Errorf("create pre-deploy hook container: %w", err) @@ -107,7 +114,6 @@ func (o *RunPreDeployOperation) waitForExit( select { case <-timeoutCtx.Done(): // Stop the container on timeout or context cancellation. - pw.Event(progress.StoppingEvent(eventID)) _ = cli.StopContainer(ctx, o.ServiceID, containerID, container.StopOptions{}) ctrID := containerID @@ -159,12 +165,25 @@ func (o *RunPreDeployOperation) waitForExit( func (o *RunPreDeployOperation) Format() string { cmd := strings.Join(o.Spec.PreDeploy.Command, " ") - return tui.BoldGreen.Render("▶") + " " + + prefix := tui.BoldGreen.Render("▶") + " " + tui.Faint.Render("run pre-deploy hook") + " " + - // TODO: truncate a long cmd to fit the width of the terminal. - o.Spec.Name + " (" + cmd + ") " + + o.Spec.Name + " [" + suffix := "] " + tui.Faint.Render("on") + " " + o.MachineName + + // Truncate the command to fit within the terminal width. + termWidth := tui.TerminalWidth() + if termWidth > 0 { + buffer := 10 + maxCmdWidth := termWidth - lipgloss.Width(prefix) - lipgloss.Width(suffix) - buffer + if maxCmdWidth <= 0 { + maxCmdWidth = 10 + } + cmd = ansi.Truncate(cmd, maxCmdWidth, "…") + } + + return prefix + cmd + suffix } func (o *RunPreDeployOperation) String() string {