package main import ( "context" "fmt" "sort" "time" "charm.land/lipgloss/v2" "github.com/docker/docker/api/types/container" "github.com/docker/go-units" "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/spf13/cobra" ) const ( sortByService = "service" sortByMachine = "machine" sortByHealth = "health" ) type containerHighlight int const ( highlightDanger containerHighlight = iota highlightWarning highlightSuccess highlightNormal ) type psOptions struct { sortBy string } func NewPsCommand() *cobra.Command { opts := psOptions{} cmd := &cobra.Command{ Use: "ps", Short: "List all service containers.", Long: `List all service containers across all machines in the cluster. This command provides a comprehensive overview of all running containers that are part of a service, making it easy to see the distribution and status of containers across the cluster.`, RunE: func(cmd *cobra.Command, args []string) error { uncli := cmd.Context().Value("cli").(*cli.CLI) if opts.sortBy != sortByService && opts.sortBy != sortByMachine && opts.sortBy != sortByHealth { return fmt.Errorf("invalid value for --sort: %q, must be one of '%s', '%s' or '%s'", opts.sortBy, sortByService, sortByMachine, sortByHealth) } return runPs(cmd.Context(), uncli, opts) }, GroupID: "service", } cmd.Flags().StringVarP(&opts.sortBy, "sort", "s", sortByService, "Sort containers by 'service', 'machine', or 'health'.") return cmd } type containerInfo struct { serviceName string machineName string id string image string status string highlight containerHighlight created time.Time ip string // Hook type (e.g., "pre-deploy"), empty for regular containers. hook string } func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error { clusterClient, err := uncli.ConnectCluster(ctx) if err != nil { return fmt.Errorf("connect to cluster: %w", err) } defer clusterClient.Close() var containers []containerInfo err = tui.RunSpinner(ctx, "Collecting container info...", func(ctx context.Context) error { containers, err = collectContainers(ctx, clusterClient) return err }) if err != nil { return fmt.Errorf("collect containers: %w", err) } // Sort the containers based on the sorting option. sort.SliceStable(containers, func(i, j int) bool { a, b := containers[i], containers[j] switch opts.sortBy { case sortByHealth: if a.highlight != b.highlight { return a.highlight < b.highlight } if a.serviceName != b.serviceName { return a.serviceName < b.serviceName } case sortByMachine: if a.machineName != b.machineName { return a.machineName < b.machineName } if a.serviceName != b.serviceName { return a.serviceName < b.serviceName } default: // sortByService if a.serviceName != b.serviceName { return a.serviceName < b.serviceName } } // Fallback to creation time (newest first). return a.created.After(b.created) }) return printContainers(containers) } func printContainers(containers []containerInfo) error { t := tui.NewTable() // Show HOOK column only when hook containers are present. hasHooks := false for _, ctr := range containers { if ctr.hook != "" { hasHooks = true break } } if hasHooks { t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "HOOK", "IP ADDRESS", "MACHINE") } else { t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE") } for _, ctr := range containers { id := ctr.id if len(id) > 12 { id = id[:12] } created := units.HumanDuration(time.Now().UTC().Sub(ctr.created)) + " ago" var statusStyle lipgloss.Style switch ctr.highlight { case highlightSuccess: statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green case highlightDanger: statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red case highlightWarning: statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow default: statusStyle = lipgloss.NewStyle() // Default } if hasHooks { t.Row( ctr.serviceName, id, tui.FormatImage(ctr.image, tui.NoStyle), created, statusStyle.Render(ctr.status), ctr.hook, ctr.ip, ctr.machineName, ) } else { t.Row( ctr.serviceName, id, tui.FormatImage(ctr.image, tui.NoStyle), created, statusStyle.Render(ctr.status), ctr.ip, ctr.machineName, ) } } lipgloss.Println(t) return nil } func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) { listCtx := cli.ProxyMachinesContext(ctx, nil) // List all service containers across all machines in the cluster. machineContainers, err := cli.Docker.ListServiceContainers( listCtx, "", container.ListOptions{All: true}, ) if err != nil { return nil, fmt.Errorf("list service containers: %w", err) } var containers []containerInfo for _, msc := range machineContainers { // NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed. if msc.Metadata == nil { tui.PrintWarning("metadata is missing in response from unknown server") continue } machineName := msc.Metadata.MachineName if msc.Metadata.Error != "" { tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s", machineName, msc.Metadata.Error)) continue } for _, ctr := range append(msc.Containers, msc.HookContainers...) { if ctr.Container.State == nil || ctr.Container.Config == nil { continue } status, err := ctr.Container.HumanState() if err != nil { return nil, fmt.Errorf("get human state for container %s: %w", ctr.Container.ID, err) } var highlight containerHighlight healthStatus := "" if ctr.Container.State.Health != nil { healthStatus = ctr.Container.State.Health.Status } if healthStatus == container.Unhealthy || ctr.Container.State.Status == "dead" || ctr.Container.State.OOMKilled || ctr.Container.State.Dead { highlight = highlightDanger } else if healthStatus == container.Healthy { highlight = highlightSuccess } else if ctr.Container.State.Status == "running" { highlight = highlightNormal } else if ctr.IsHook() && ctr.Container.State.Status == "exited" && ctr.Container.State.ExitCode == 0 { // Hook containers (e.g., pre-deploy) are expected to exit successfully. highlight = highlightNormal } else { // Other non-critical but noteworthy states highlight = highlightWarning } created, _ := time.Parse(time.RFC3339Nano, ctr.Container.Created) ip := ctr.Container.UncloudNetworkIP() ipStr := "" // The container might not have an IP if it's not running or uses the host network. if ip.IsValid() { ipStr = ip.String() } info := containerInfo{ serviceName: ctr.ServiceName(), machineName: machineName, id: ctr.Container.ID, image: ctr.Container.Config.Image, status: status, highlight: highlight, created: created, ip: ipStr, hook: ctr.Config.Labels[api.LabelHook], } containers = append(containers, info) } } return containers, nil }