From 8afe52367fa40f78b67c77c606d15eda204bb06f Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Wed, 22 Apr 2026 12:43:19 +1000 Subject: [PATCH] feat(deploy): print last logs from failed pre-deploy hook --- cmd/uncloud/deploy.go | 47 +++++++++++++++++++++++- pkg/api/client.go | 8 ++++ pkg/client/deploy/operation/predeploy.go | 30 ++++++++++++--- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/cmd/uncloud/deploy.go b/cmd/uncloud/deploy.go index e90443e9..f9961c24 100644 --- a/cmd/uncloud/deploy.go +++ b/cmd/uncloud/deploy.go @@ -10,13 +10,20 @@ import ( 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/logs" "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" ) +// failedContainerLogsTail is the number of recent log lines to print from a failed container to give the user immediate +// context without requiring a follow-up 'uc logs' invocation. +const failedContainerLogsTail = 10 + type deployOptions struct { cli.BuildServicesOptions @@ -223,10 +230,48 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { if deployTarget != "" { title += " to " + tui.NameStyle.Render(deployTarget) } - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + err = 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(), title) + if err != nil { + fmt.Println() + if hookErr, ok := errors.AsType[*operation.PreDeployHookError](err); ok { + printPreDeployHookLogs(ctx, clusterClient, hookErr) + fmt.Println() + } + return err + } + return nil +} + +// printPreDeployHookLogs fetches the last log lines from the failed pre-deploy hook container and prints them using +// the standard log formatter. +func printPreDeployHookLogs(ctx context.Context, cli *client.Client, hookErr *operation.PreDeployHookError) { + _, ch, err := cli.ServiceLogs(ctx, hookErr.ServiceName, api.ServiceLogsOptions{ + Containers: []string{hookErr.ContainerID}, + Machines: []string{hookErr.MachineName}, + Tail: failedContainerLogsTail, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch pre-deploy hook logs: %v\n", err) + fmt.Fprintf(os.Stderr, "You can try manually with: uc logs %s\n", hookErr.ServiceName) + return + } + + header := fmt.Sprintf("Last %d log lines from failed pre-deploy hook:", failedContainerLogsTail) + fmt.Println(tui.BoldRed.Render(header)) + + logsEmpty := true + formatter := logs.NewFormatter([]string{hookErr.MachineName}, []string{hookErr.ServiceName}, false) + for entry := range ch { + logsEmpty = false + formatter.PrintEntry(entry) + } + + if logsEmpty { + fmt.Println("") + } } diff --git a/pkg/api/client.go b/pkg/api/client.go index 2980119f..e75ea37e 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -12,6 +12,7 @@ type Client interface { ContainerClient DNSClient ImageClient + LogsClient MachineClient ServiceClient VolumeClient @@ -43,6 +44,13 @@ type ImageClient interface { InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error) } +type LogsClient interface { + ServiceLogs( + ctx context.Context, serviceNameOrID string, opts ServiceLogsOptions, + ) (Service, <-chan ServiceLogEntry, error) + MachineLogs(ctx context.Context, unit string, opts ServiceLogsOptions) (<-chan ServiceLogEntry, error) +} + type MachineClient interface { InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error) ListMachines(ctx context.Context, filter *MachineFilter) (MachineMembersList, error) diff --git a/pkg/client/deploy/operation/predeploy.go b/pkg/client/deploy/operation/predeploy.go index 54a61f91..bace2bbc 100644 --- a/pkg/client/deploy/operation/predeploy.go +++ b/pkg/client/deploy/operation/predeploy.go @@ -16,6 +16,14 @@ import ( "github.com/psviderski/uncloud/pkg/api" ) +// PreDeployHookError indicates that a pre-deploy hook container exited with a non-zero code or timed out. +type PreDeployHookError struct { + error + ServiceName string + ContainerID string + MachineName string +} + // DefaultPreDeployTimeout is the maximum duration to wait for a pre-deploy hook container to complete. const DefaultPreDeployTimeout = 5 * time.Minute @@ -128,9 +136,14 @@ func (o *RunPreDeployOperation) waitForExit( return fmt.Errorf("pre-deploy hook container '%s': %w", ctrID, ctx.Err()) } pw.Event(progress.NewEvent(eventID, progress.Error, fmt.Sprintf("Timeout (%s)", timeout))) - return fmt.Errorf("pre-deploy hook container '%s' timed out after %s. "+ - "It's stopped and available for inspection. Fetch logs with 'uc logs %s'", - ctrID, timeout, o.Spec.Name) + return &PreDeployHookError{ + ServiceName: o.Spec.Name, + ContainerID: containerID, + MachineName: o.MachineName, + error: fmt.Errorf("pre-deploy hook container '%s' timed out after %s. "+ + "It's stopped and available for inspection. View logs with 'uc logs %s'", + ctrID, timeout, o.Spec.Name), + } case <-ticker.C: mc, err := cli.InspectContainer(ctx, o.ServiceID, containerID) @@ -157,9 +170,14 @@ func (o *RunPreDeployOperation) waitForExit( pw.Event(progress.ErrorEvent(eventID)) ctrID := fmt.Sprintf("%s/%s", o.Spec.Name, ctr.ShortID()) - return fmt.Errorf("pre-deploy hook container '%s' failed with exit code: %d. "+ - "It's stopped and available for inspection. Fetch logs with 'uc logs %s'", - ctrID, ctr.State.ExitCode, o.Spec.Name) + return &PreDeployHookError{ + ServiceName: o.Spec.Name, + ContainerID: containerID, + MachineName: o.MachineName, + error: fmt.Errorf("pre-deploy hook container '%s' failed with exit code: %d. "+ + "It's stopped and available for inspection. View logs with 'uc logs %s'", + ctrID, ctr.State.ExitCode, o.Spec.Name), + } } } }