mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
refactor(cli): move cmd/uncloud to cmd/uc, change the artifact name uncloud_* -> uc_*
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user