mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
270 lines
7.2 KiB
Go
270 lines
7.2 KiB
Go
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
|
|
}
|