package image import ( "context" "fmt" "os" "slices" "sort" "strings" "time" "charm.land/lipgloss/v2" "github.com/charmbracelet/colorprofile" "github.com/containerd/platforms" "github.com/docker/docker/api/types/image" "github.com/docker/go-units" "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" ) type listOptions struct { machines []string nameFilter string } func NewListCommand() *cobra.Command { opts := listOptions{} cmd := &cobra.Command{ Use: "ls [REPO:[TAG]]", Aliases: []string{"list"}, Short: "List images on machines in the cluster.", Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.", Example: ` # List all 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 # List images filtered by name (with any tag) on all machines. uc image ls myapp # List images filtered by name pattern on specific machine. uc image ls "myapp:1.*" -m machine1`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.nameFilter = args[0] } 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)") completion.MachinesFlag(cmd) return cmd } // imageRow represents a single image with its metadata for display. type imageRow struct { id string name string platforms string createdHuman string createdUnix int64 size string inUse string store string machine string } func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { clusterClient, err := uncli.ConnectCluster(ctx) if err != nil { return fmt.Errorf("connect to cluster: %w", err) } defer clusterClient.Close() machines := cli.ExpandCommaSeparatedValues(opts.machines) clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{ Machines: machines, Name: opts.nameFilter, }) if err != nil { return fmt.Errorf("list images: %w", err) } // Collect all images from all machines. var rows []imageRow for _, machineImages := range clusterImages { if err := machineImages.Error(); err != nil { tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s", machineImages.Metadata.MachineName, err)) continue } // Get machine name for better readability. machineName := machineImages.Metadata.MachineName store := "docker" if machineImages.ContainerdStore { store = "containerd" } // Process each image for this machine. for _, img := range machineImages.Images { // Show the first 12 chars without 'sha256:' as the image ID like Docker does. id := strings.TrimPrefix(img.ID, "sha256:")[:12] name := "" if len(img.RepoTags) > 0 && img.RepoTags[0] != ":" { name = img.RepoTags[0] } imgPlatforms, _ := imagePlatforms(img) formattedPlatforms := formatPlatforms(imgPlatforms) 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) // Check if the image is in use by any containers. Only supported by Docker API >=1.51 inUse := "-" if img.Containers != -1 { // -1 means the info is not available. if img.Containers > 0 { inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●") } else { inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○") } } rows = append(rows, imageRow{ id: id, name: name, platforms: formattedPlatforms, createdHuman: created, createdUnix: img.Created, size: size, inUse: inUse, store: store, machine: machineName, }) } } if len(rows) == 0 { if opts.nameFilter != "" { fmt.Printf("No images matching '%s' found.\n", opts.nameFilter) } else { fmt.Println("No images found.") } return nil } // Sort images by name, then by machine name. sort.Slice(rows, func(i, j int) bool { if rows[i].name != rows[j].name { return rows[i].name < rows[j].name } return rows[i].machine < rows[j].machine }) // Print the images in a table format. lipgloss.Println(formatImageTable(rows)) return nil } // imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform. func imagePlatforms(img image.Summary) ([]string, bool) { var formattedPlatforms []string multiPlatform := false for _, m := range img.Manifests { if m.Kind != image.ManifestKindImage || !m.Available { continue } if m.ID != img.ID { // There is an image manifest that has digest different from the main image digest. // This means the image manifest is an index or a manifest list (multi-platform image). multiPlatform = true } formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform)) } slices.Sort(formattedPlatforms) return formattedPlatforms, multiPlatform } func formatPlatforms(platforms []string) string { if len(platforms) == 0 { return "-" } platformStyle := lipgloss.NewStyle(). BorderForeground(lipgloss.Color("152")). Foreground(lipgloss.Color("0")). Background(lipgloss.Color("152")) // Use fancy pill borders only if the output is a terminal with color support. if colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII { platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true) } styledPlatforms := make([]string, len(platforms)) for i, p := range platforms { styledPlatforms[i] = platformStyle.Render(p) } return strings.Join(styledPlatforms, " ") } func formatImageTable(rows []imageRow) string { columns := []struct { name string hide bool }{ {name: "IMAGE ID"}, {name: "NAME"}, {name: "PLATFORMS"}, {name: "CREATED"}, {name: "SIZE"}, {name: "IN USE"}, {name: "STORE"}, {name: "MACHINE"}, } // Hide the "IN USE" column if none of the images have that info available. inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool { return r.inUse != "-" }) if !inUseInfoAvailable { // Hide "IN USE" column. columns[5].hide = true } // Hide the "STORE" column if all machines use the containerd store. hasNonContainerd := slices.ContainsFunc(rows, func(r imageRow) bool { return r.store != "containerd" }) if !hasNonContainerd { columns[6].hide = true } t := tui.NewTable() var headers []string for _, col := range columns { if !col.hide { headers = append(headers, col.name) } } t.Headers(headers...) for _, row := range rows { values := []string{ row.id, tui.FormatImage(row.name, tui.NoStyle), row.platforms, row.createdHuman, row.size, row.inUse, row.store, row.machine, } var filteredValues []string for i, v := range values { if !columns[i].hide { filteredValues = append(filteredValues, v) } } t.Row(filteredValues...) } return t.String() }