From 6d2e100d631c9ca53276a1b0e3283d157b6efdc6 Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Fri, 3 Oct 2025 20:53:30 +1000 Subject: [PATCH] feat(images): 'uc image ls' and 'uc images' (alias) commands to list images on machines --- cmd/uncloud/image/ls.go | 147 ++++++++++++++++++++++++++++++++++++++ cmd/uncloud/image/root.go | 3 +- cmd/uncloud/images.go | 19 +++++ cmd/uncloud/main.go | 1 + pkg/api/client.go | 27 +++++-- pkg/client/image.go | 9 ++- 6 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 cmd/uncloud/image/ls.go create mode 100644 cmd/uncloud/images.go diff --git a/cmd/uncloud/image/ls.go b/cmd/uncloud/image/ls.go new file mode 100644 index 00000000..92fc0b15 --- /dev/null +++ b/cmd/uncloud/image/ls.go @@ -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 := "" + if len(img.RepoTags) > 0 && img.RepoTags[0] != ":" { + 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() +} diff --git a/cmd/uncloud/image/root.go b/cmd/uncloud/image/root.go index fb3b6025..53ddfee6 100644 --- a/cmd/uncloud/image/root.go +++ b/cmd/uncloud/image/root.go @@ -7,10 +7,11 @@ import ( func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ Use: "image", - Short: "Manage Docker images in a cluster.", + Short: "Manage images on machines in the cluster.", } cmd.AddCommand( + NewListCommand(), NewPushCommand(), ) diff --git a/cmd/uncloud/images.go b/cmd/uncloud/images.go new file mode 100644 index 00000000..9e4ceaec --- /dev/null +++ b/cmd/uncloud/images.go @@ -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 +} diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 3e17c8b3..82833d2b 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -80,6 +80,7 @@ func main() { NewDeployCommand(), NewDocsCommand(), NewBuildCommand(), + NewImagesCommand(), caddy.NewRootCommand(), cmdcontext.NewRootCommand(), dns.NewRootCommand(), diff --git a/pkg/api/client.go b/pkg/api/client.go index 4ab440fb..1a7f688f 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -3,7 +3,7 @@ package api import ( "context" "fmt" - "slices" + "strings" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/volume" @@ -70,15 +70,28 @@ func ProxyMachinesContext( } var proxiedMachines MachineMembersList - md := metadata.New(nil) - for _, m := range machines { - if len(namesOrIDs) == 0 || - slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) { + var notFound []string + for _, nameOrID := range namesOrIDs { + if m := machines.FindByNameOrID(nameOrID); m != nil { proxiedMachines = append(proxiedMachines, m) - machineIP, _ := m.Machine.Network.ManagementIp.ToAddr() - md.Append("machines", machineIP.String()) + } else { + 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 } diff --git a/pkg/client/image.go b/pkg/client/image.go index 0b83df68..4216587c 100644 --- a/pkg/client/image.go +++ b/pkg/client/image.go @@ -65,7 +65,13 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap machineImages := make([]api.MachineImages, len(resp.Messages)) for i, msg := range resp.Messages { 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. // 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 { @@ -81,6 +87,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap return nil, fmt.Errorf("unmarshal images: %w", err) } } + machineImages[i].ContainerdStore = msg.ContainerdStore } return machineImages, nil