From 7e8736fa996fd078cbd7be6b02137cdf1462748d Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Mon, 26 Oct 2026 20:00:46 +1000 Subject: [PATCH] feat(images): include all tags and digest references when listing images --- cmd/uc/image/ls.go | 97 +++++++++++++++++++++++++-------------------- pkg/api/image.go | 23 +++++++++++ pkg/client/image.go | 2 +- 3 files changed, 77 insertions(+), 45 deletions(-) diff --git a/cmd/uc/image/ls.go b/cmd/uc/image/ls.go index e725fe86..59076181 100644 --- a/cmd/uc/image/ls.go +++ b/cmd/uc/image/ls.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/colorprofile" "github.com/containerd/platforms" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/pkg/stringid" "github.com/docker/go-units" "github.com/psviderski/uncloud/internal/cli" @@ -116,49 +117,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { 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, - }) - } + rows = append(rows, imageRows(machineImages.Images, machineName, store)...) } if len(rows) == 0 { @@ -184,13 +143,63 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { return nil } +// imageRows keeps every tag visible and retains digest-only and unnamed images. +func imageRows(images []image.Summary, machineName, store string) []imageRow { + var rows []imageRow + for _, img := range images { + imageRefs := api.ImageReferences(img) + var imageNames []string + for _, ref := range imageRefs { + if !strings.Contains(ref, "@") { + imageNames = append(imageNames, ref) + } + } + if len(imageNames) == 0 { + imageNames = imageRefs + } + if len(imageNames) == 0 { + imageNames = []string{""} + } + + imgPlatforms, _ := imagePlatforms(img) + created := "" + if img.Created > 0 { + created = units.HumanDuration(time.Since(time.Unix(img.Created, 0))) + " ago" + } + // Check if the image is in use by any containers. Only supported by Docker API >=1.51 + inUse := "-" + if img.Containers >= 0 { // -1 means the info is not available. + inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○") + if img.Containers > 0 { + inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●") + } + } + + for _, name := range imageNames { + rows = append(rows, imageRow{ + id: stringid.TruncateID(img.ID), + name: name, + platforms: formatPlatforms(imgPlatforms), + createdHuman: created, + createdUnix: img.Created, + size: units.HumanSizeWithPrecision(float64(img.Size), 3), + inUse: inUse, + store: store, + machine: machineName, + }) + } + } + + return rows +} + // 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 { + if m.Kind != image.ManifestKindImage || !m.Available || m.ImageData == nil { continue } diff --git a/pkg/api/image.go b/pkg/api/image.go index a3d75af3..1276f79b 100644 --- a/pkg/api/image.go +++ b/pkg/api/image.go @@ -2,6 +2,7 @@ package api import ( "errors" + "slices" "github.com/distribution/reference" "github.com/docker/docker/api/types/image" @@ -42,6 +43,28 @@ type ImageFilter struct { Name string } +// ImageReferences returns the image tag and named digest references, sorted and without duplicates. +// References use familiar names such as "alpine:latest" and "alpine@sha256:...". +// Bare digests and invalid references are excluded. It returns nil if there are no named references. +func ImageReferences(img image.Summary) []string { + var refs []string + for _, names := range [][]string{img.RepoTags, img.RepoDigests} { + for _, name := range names { + ref, err := reference.ParseAnyReference(name) + if err != nil { + continue + } + // RepoDigests can contain bare digests. Keep only references with a repository name. + if named, ok := ref.(reference.Named); ok { + refs = append(refs, reference.FamiliarString(named)) + } + } + } + + slices.Sort(refs) + return slices.Compact(refs) +} + // MachineRemoteImage represents an image in a remote registry fetched by a particular machine. type MachineRemoteImage struct { Metadata *pb.Metadata diff --git a/pkg/client/image.go b/pkg/client/image.go index b553a5f0..950c6173 100644 --- a/pkg/client/image.go +++ b/pkg/client/image.go @@ -61,7 +61,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap // Broadcast the image list request to the specified machines or all machines if none specified. listCtx := ProxyMachinesContext(ctx, filter.Machines) - opts := image.ListOptions{Manifests: true} + opts := image.ListOptions{All: true, Manifests: true} if filter.Name != "" { opts.Filters = filters.NewArgs( filters.Arg("reference", filter.Name),