feat(images): 'uc image ls' and 'uc images' (alias) commands to list images on machines

This commit is contained in:
Pasha Sviderski
2025-10-03 20:53:30 +10:00
parent cce81ecaad
commit 6d2e100d63
6 changed files with 197 additions and 9 deletions
+147
View File
@@ -0,0 +1,147 @@
package image
import (
"context"
"fmt"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type listOptions struct {
machines []string
context string
}
func NewListCommand() *cobra.Command {
opts := listOptions{}
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List images on machines in the cluster.",
Long: "List images on machines in the cluster. By default, on all machines.",
Example: ` # List images on all machines.
uc image ls
# List images on specific machine.
uc image ls -m machine1
# List images on multiple machines.
uc image ls -m machine1,machine2`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
"(default is include all machines)")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
// Get all machines to create ID to name mapping.
allMachines, err := clusterClient.ListMachines(ctx, nil)
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineIDToName := make(map[string]string)
for _, machineMember := range allMachines {
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
}
}
machines := cli.ExpandCommaSeparatedValues(opts.machines)
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{Machines: machines})
if err != nil {
return fmt.Errorf("list images: %w", err)
}
// Check if there are any images across all machines.
hasImages := false
for _, machineImages := range clusterImages {
if len(machineImages.Images) > 0 {
hasImages = true
break
}
}
if !hasImages {
fmt.Println("No images found.")
return nil
}
// Replace machine IDs with names in metadata for better readability.
for _, machineImages := range clusterImages {
if m := allMachines.FindByNameOrID(machineImages.Metadata.Machine); m != nil {
machineImages.Metadata.Machine = m.Machine.Name
}
}
// Sort machines alphabetically by name.
sort.Slice(clusterImages, func(i, j int) bool {
return clusterImages[i].Metadata.Machine < clusterImages[j].Metadata.Machine
})
// Print the images in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
if _, err = fmt.Fprintln(tw, "MACHINE\tNAME\tIMAGE ID\tCREATED\tSIZE\tSTORE"); err != nil {
return fmt.Errorf("write header: %w", err)
}
// Print rows for each machine's images.
for _, machineImages := range clusterImages {
store := "docker"
if machineImages.ContainerdStore {
store = "containerd"
}
// Print each image for this machine.
for _, img := range machineImages.Images {
imageName := "<none>"
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
imageName = img.RepoTags[0]
}
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
imageID := strings.TrimPrefix(img.ID, "sha256:")[:12]
created := ""
createdAt := time.Unix(img.Created, 0)
if !createdAt.IsZero() {
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
}
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
if _, err = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
machineImages.Metadata.Machine, imageName, imageID, created, size, store); err != nil {
return fmt.Errorf("write row: %w", err)
}
}
}
return tw.Flush()
}
+2 -1
View File
@@ -7,10 +7,11 @@ import (
func NewRootCommand() *cobra.Command { func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "image", Use: "image",
Short: "Manage Docker images in a cluster.", Short: "Manage images on machines in the cluster.",
} }
cmd.AddCommand( cmd.AddCommand(
NewListCommand(),
NewPushCommand(), NewPushCommand(),
) )
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"strings"
"github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/spf13/cobra"
)
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
func NewImagesCommand() *cobra.Command {
listCmd := image.NewListCommand()
listCmd.Use = "images"
// Remove 'list' alias since this command is already an alias.
listCmd.Aliases = nil
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
return listCmd
}
+1
View File
@@ -80,6 +80,7 @@ func main() {
NewDeployCommand(), NewDeployCommand(),
NewDocsCommand(), NewDocsCommand(),
NewBuildCommand(), NewBuildCommand(),
NewImagesCommand(),
caddy.NewRootCommand(), caddy.NewRootCommand(),
cmdcontext.NewRootCommand(), cmdcontext.NewRootCommand(),
dns.NewRootCommand(), dns.NewRootCommand(),
+20 -7
View File
@@ -3,7 +3,7 @@ package api
import ( import (
"context" "context"
"fmt" "fmt"
"slices" "strings"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
@@ -70,15 +70,28 @@ func ProxyMachinesContext(
} }
var proxiedMachines MachineMembersList var proxiedMachines MachineMembersList
md := metadata.New(nil) var notFound []string
for _, m := range machines { for _, nameOrID := range namesOrIDs {
if len(namesOrIDs) == 0 || if m := machines.FindByNameOrID(nameOrID); m != nil {
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
proxiedMachines = append(proxiedMachines, m) proxiedMachines = append(proxiedMachines, m)
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr() } else {
md.Append("machines", machineIP.String()) notFound = append(notFound, nameOrID)
} }
} }
if len(notFound) > 0 {
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
}
if len(namesOrIDs) == 0 {
proxiedMachines = machines
}
md := metadata.New(nil)
for _, m := range proxiedMachines {
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
md.Append("machines", machineIP.String())
}
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
} }
+8 -1
View File
@@ -65,7 +65,13 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
machineImages := make([]api.MachineImages, len(resp.Messages)) machineImages := make([]api.MachineImages, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
machineImages[i].Metadata = msg.Metadata machineImages[i].Metadata = msg.Metadata
if msg.Metadata != nil { // TODO: handle this in the grpc-proxy router and always provide Metadata if possible.
if msg.Metadata == nil {
// Metadata can be nil if the request was broadcasted to only one machine.
machineImages[i].Metadata = &pb.Metadata{
Machine: machines[0].Machine.Id,
}
} else {
// Replace management IP with machine ID for friendlier error messages. // Replace management IP with machine ID for friendlier error messages.
// TODO: migrate Metadata.Machine to use machine ID instead of IP in the grpc-proxy router. // TODO: migrate Metadata.Machine to use machine ID instead of IP in the grpc-proxy router.
if m := machines.FindByManagementIP(msg.Metadata.Machine); m != nil { if m := machines.FindByManagementIP(msg.Metadata.Machine); m != nil {
@@ -81,6 +87,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
return nil, fmt.Errorf("unmarshal images: %w", err) return nil, fmt.Errorf("unmarshal images: %w", err)
} }
} }
machineImages[i].ContainerdStore = msg.ContainerdStore
} }
return machineImages, nil return machineImages, nil