feat(pre-deploy): correct progress events for pre-deploy hooks, make other container events more aligned with plan

This commit is contained in:
Pasha Sviderski
2026-04-08 19:16:42 +10:00
parent 0e820f9c52
commit 0da9df17a4
8 changed files with 110 additions and 27 deletions
+1 -1
View File
@@ -14,6 +14,7 @@ require (
github.com/caddyserver/caddy/v2 v2.8.4 github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/colorprofile v0.4.2 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/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1 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 v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // 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/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
+40 -3
View File
@@ -1,12 +1,49 @@
package progress package progress
import ( import (
"fmt" "context"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui" "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 { 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
} }
+10
View File
@@ -51,3 +51,13 @@ func IsStdinTerminal() bool {
func IsStdoutTerminal() bool { func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd())) 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
}
+2 -2
View File
@@ -20,10 +20,10 @@ type Client interface {
type ContainerClient interface { type ContainerClient interface {
CreateContainer( CreateContainer(
ctx context.Context, serviceID string, spec ServiceSpec, machineID string, ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
) (container.CreateResponse, error) ) (CreateContainerResponse, error)
CreatePreDeployHookContainer( CreatePreDeployHookContainer(
ctx context.Context, serviceID string, spec ServiceSpec, machineID string, 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) ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error) InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
+7
View File
@@ -164,6 +164,13 @@ func (c *Container) UnmarshalJSON(data []byte) error {
return nil 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 { type ServiceContainer struct {
Container Container
ServiceSpec ServiceSpec ServiceSpec ServiceSpec
+16 -13
View File
@@ -2,13 +2,12 @@ package client
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"encoding/json"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/jsonmessage"
@@ -28,7 +27,7 @@ import (
// CreateContainer creates a new container for the given service on the specified machine. // CreateContainer creates a new container for the given service on the specified machine.
func (cli *Client) CreateContainer( func (cli *Client) CreateContainer(
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string, 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) return cli.createServiceContainerWithPull(ctx, serviceID, spec, machineID, pb.CreateServiceContainerRequest_SERVICE)
} }
@@ -36,7 +35,7 @@ func (cli *Client) CreateContainer(
// on the specified machine. // on the specified machine.
func (cli *Client) CreatePreDeployHookContainer( func (cli *Client) CreatePreDeployHookContainer(
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string, ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string,
) (container.CreateResponse, error) { ) (api.CreateContainerResponse, error) {
return cli.createServiceContainerWithPull( return cli.createServiceContainerWithPull(
ctx, serviceID, spec, machineID, pb.CreateServiceContainerRequest_PRE_DEPLOY) ctx, serviceID, spec, machineID, pb.CreateServiceContainerRequest_PRE_DEPLOY)
} }
@@ -49,8 +48,8 @@ func (cli *Client) createServiceContainerWithPull(
spec api.ServiceSpec, spec api.ServiceSpec,
machineID string, machineID string,
containerType pb.CreateServiceContainerRequest_ContainerType, containerType pb.CreateServiceContainerRequest_ContainerType,
) (container.CreateResponse, error) { ) (api.CreateContainerResponse, error) {
var resp container.CreateResponse var resp api.CreateContainerResponse
spec = spec.SetDefaults() spec = spec.SetDefaults()
if err := spec.Validate(); err != nil { if err := spec.Validate(); err != nil {
@@ -69,16 +68,16 @@ func (cli *Client) createServiceContainerWithPull(
} }
containerName := fmt.Sprintf("%s-%s", spec.Name, suffix) 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 { if containerType == pb.CreateServiceContainerRequest_PRE_DEPLOY {
containerName = fmt.Sprintf("%s-%s-%s", spec.Name, api.LabelHookPreDeploy, suffix) 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. // Proxy Docker gRPC requests to the selected machine.
ctx = proxyToMachine(ctx, machine.Machine) ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx) pw := progress.ContextWriter(ctx)
eventID := cliprogress.NewContainerEventID(ctx, containerName, machine.Machine.Name)
pw.Event(progress.CreatingEvent(eventID)) pw.Event(progress.CreatingEvent(eventID))
if spec.Container.PullPolicy == api.PullPolicyAlways { 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) return resp, fmt.Errorf("unmarshal gRPC response: %w", err)
} }
pw.Event(progress.CreatedEvent(eventID)) pw.Event(progress.CreatedEvent(eventID))
@@ -293,7 +292,7 @@ func (cli *Client) StartContainer(ctx context.Context, serviceNameOrID, containe
ctx = proxyToMachine(ctx, machine.Machine) ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx) 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)) pw.Event(progress.StartingEvent(eventID))
if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil { 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. // StopContainer stops the specified container within the service.
//
//nolint:dupl // Structurally similar to RemoveContainer but performs a different operation.
func (cli *Client) StopContainer( func (cli *Client) StopContainer(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions, ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions,
) error { ) error {
@@ -320,7 +321,7 @@ func (cli *Client) StopContainer(
ctx = proxyToMachine(ctx, machine.Machine) ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx) 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)) pw.Event(progress.StoppingEvent(eventID))
if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil { 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. // RemoveContainer removes the specified container within the service.
//
//nolint:dupl // Structurally similar to StopContainer but performs a different operation.
func (cli *Client) RemoveContainer( func (cli *Client) RemoveContainer(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions, ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions,
) error { ) error {
@@ -347,7 +350,7 @@ func (cli *Client) RemoveContainer(
ctx = proxyToMachine(ctx, machine.Machine) ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx) 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)) pw.Event(progress.RemovingEvent(eventID))
if err = cli.Docker.RemoveServiceContainer(ctx, ctr.Container.ID, opts); err != nil { if err = cli.Docker.RemoveServiceContainer(ctx, ctr.Container.ID, opts); err != nil {
@@ -427,7 +430,7 @@ func (cli *Client) WaitContainerHealthy(
} }
pw := progress.ContextWriter(ctx) 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 var monitor time.Duration
if opts.MonitorPeriod == nil { if opts.MonitorPeriod == nil {
+9 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stringid" "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/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -28,6 +29,10 @@ func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
if err != nil { if err != nil {
return fmt.Errorf("create container: %w", err) 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 { if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start container: %w", err) return fmt.Errorf("start container: %w", err)
} }
@@ -171,13 +176,15 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
if err != nil { if err != nil {
return fmt.Errorf("create new container: %w", err) 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) return fmt.Errorf("start new container: %w", err)
} }
if !o.SkipHealthMonitor { if !o.SkipHealthMonitor {
opts := api.WaitContainerHealthyOptions{MonitorPeriod: o.Spec.UpdateConfig.MonitorPeriod} 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. // 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. // 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. // TODO: collect logs from the new container and include in the error message to speed up debugging.
+25 -6
View File
@@ -6,6 +6,8 @@ import (
"strings" "strings"
"time" "time"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/containerd/errdefs" "github.com/containerd/errdefs"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -65,13 +67,18 @@ type RunPreDeployOperation struct {
func (o *RunPreDeployOperation) Execute(ctx context.Context, cli Client) error { func (o *RunPreDeployOperation) Execute(ctx context.Context, cli Client) error {
// Remove old pre-deploy containers. // Remove old pre-deploy containers.
for _, id := range o.OldContainerIDs { for _, id := range o.OldContainerIDs {
_ = cli.StopContainer(ctx, o.ServiceID, id, container.StopOptions{}) oldCtx := cliprogress.WithEventID(ctx, cliprogress.OldPreDeployHookEventID(o.Spec.Name, id, o.MachineName))
err := cli.RemoveContainer(ctx, o.ServiceID, id, container.RemoveOptions{RemoveVolumes: true}) _ = 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) { if err != nil && !errdefs.IsNotFound(err) {
return fmt.Errorf("remove old pre-deploy hook container '%s': %w", id, 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) resp, err := cli.CreatePreDeployHookContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil { if err != nil {
return fmt.Errorf("create pre-deploy hook container: %w", err) return fmt.Errorf("create pre-deploy hook container: %w", err)
@@ -107,7 +114,6 @@ func (o *RunPreDeployOperation) waitForExit(
select { select {
case <-timeoutCtx.Done(): case <-timeoutCtx.Done():
// Stop the container on timeout or context cancellation. // Stop the container on timeout or context cancellation.
pw.Event(progress.StoppingEvent(eventID))
_ = cli.StopContainer(ctx, o.ServiceID, containerID, container.StopOptions{}) _ = cli.StopContainer(ctx, o.ServiceID, containerID, container.StopOptions{})
ctrID := containerID ctrID := containerID
@@ -159,12 +165,25 @@ func (o *RunPreDeployOperation) waitForExit(
func (o *RunPreDeployOperation) Format() string { func (o *RunPreDeployOperation) Format() string {
cmd := strings.Join(o.Spec.PreDeploy.Command, " ") cmd := strings.Join(o.Spec.PreDeploy.Command, " ")
return tui.BoldGreen.Render("▶") + " " + prefix := tui.BoldGreen.Render("▶") + " " +
tui.Faint.Render("run pre-deploy hook") + " " + tui.Faint.Render("run pre-deploy hook") + " " +
// TODO: truncate a long cmd to fit the width of the terminal. o.Spec.Name + " ["
o.Spec.Name + " (" + cmd + ") " + suffix := "] " +
tui.Faint.Render("on") + " " + tui.Faint.Render("on") + " " +
o.MachineName 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 { func (o *RunPreDeployOperation) String() string {