mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat(images): 'uc image ls' and 'uc images' (alias) commands to list images on machines
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user