Files
docker-cli/cli/command/image/tree.go
Sebastiaan van Stijn f18e239a53 docker images --tree: hide both untagged and dangling images by default
Before this patch, `docker image ls` / `docker image ls` would always
show untagged images, but hide "dangling" images (which effectively
only were produced by the legacy builder) unless `-a` / `--all` was
used. This often resulted in many `<none>:<none>` or `<untagged>` images
to be shown, which had little value to interact with, other than to
garbage collect (`docker system prune`).

In future, we want to take more advantage of containerd's garbage-collecting
features (removing unused images automatically), and this UX change is
a stepping stone toward that.

For now, this patch only changes the behavior for `docker image ls --tree`,
but we should make this the same for "non" --tree as well.

This patch:

- changes `docker image ls` to hide both "untagged" and "dangling" images
  by default.
- changes the behavior of `--all` on the client side to make them visible

The API response remains the same for now, but this is something we can
consider changing in future (possibly more granular than a single boolean).

Before this patch;

    docker image ls --tree
                                                                           i Info →   U  In Use

    IMAGE                                      ID             DISK USAGE   CONTENT SIZE   EXTRA
    docker:cli                                 28fb556c1ea1        276MB         69.8MB
    ├─ linux/amd64                             828f4f57525d           0B             0B
    ├─ linux/arm/v6                            563c0b58e54b           0B             0B
    ├─ linux/arm/v7                            6045d4846c59           0B             0B
    └─ linux/arm64/v8                          11e8dfd68841        276MB         69.8MB

    alpine:latest                              a8560b36e8b8       12.8MB         3.99MB    U
    ├─ linux/amd64                             1c4eef651f65           0B             0B
    ├─ linux/arm/v6                            903bfe2ae994           0B             0B
    ├─ linux/arm/v7                            9c2d245b3c01           0B             0B
    ├─ linux/arm64/v8                          757d680068d7       12.8MB         3.99MB    U
    ├─ linux/386                               2436f2b3b7d2           0B             0B
    ├─ linux/ppc64le                           9ed53fd3b831           0B             0B
    ├─ linux/riscv64                           1de5eb4a9a67           0B             0B
    └─ linux/s390x                             fe0dcdd1f783           0B             0B

    <untagged>                                 c6c1bcb0fd8d       12.8MB         3.99MB
    └─ linux/arm64                             cb171c618ae8       12.8MB         3.99MB

    <untagged>                                 7361ef970703       12.8MB         3.99MB
    └─ linux/arm64                             07033f43e44a       12.8MB         3.99MB

    <untagged>                                 0c62c63b81ec       12.8MB         3.99MB
    └─ linux/arm64                             94742272117f       12.8MB         3.99MB

    <untagged>                                 91dd947eebd0       12.8MB         3.99MB
    └─ linux/arm64                             ee55d203e26f       12.8MB         3.99MB

    <untagged>                                 382d9f57e8d8       12.8MB         3.99MB
    └─ linux/arm64                             5256d47804e3       12.8MB         3.99MB

    <untagged>                                 56fa17d2a7e7       12.8MB         3.99MB
    ├─ linux/amd64                             483f502c0e6a           0B             0B
    ├─ linux/arm/v6                            c79529000bdf           0B             0B
    ├─ linux/arm/v7                            cc455d4b2c47           0B             0B
    ├─ linux/arm64/v8                          508c1b94e1d2       12.8MB         3.99MB
    ├─ linux/386                               f32403957113           0B             0B
    ├─ linux/ppc64le                           23dbce23b88f           0B             0B
    ├─ linux/riscv64                           f9d2da150cee           0B             0B
    └─ linux/s390x                             6bb03952a007           0B             0B

After this patch

    docker image ls --tree
                                                                           i Info →   U  In Use

    IMAGE                                      ID             DISK USAGE   CONTENT SIZE   EXTRA
    docker:cli                                 28fb556c1ea1        276MB         69.8MB
    ├─ linux/amd64                             828f4f57525d           0B             0B
    ├─ linux/arm/v6                            563c0b58e54b           0B             0B
    ├─ linux/arm/v7                            6045d4846c59           0B             0B
    └─ linux/arm64/v8                          11e8dfd68841        276MB         69.8MB

    alpine:latest                              a8560b36e8b8       12.8MB         3.99MB    U
    ├─ linux/amd64                             1c4eef651f65           0B             0B
    ├─ linux/arm/v6                            903bfe2ae994           0B             0B
    ├─ linux/arm/v7                            9c2d245b3c01           0B             0B
    ├─ linux/arm64/v8                          757d680068d7       12.8MB         3.99MB    U
    ├─ linux/386                               2436f2b3b7d2           0B             0B
    ├─ linux/ppc64le                           9ed53fd3b831           0B             0B
    ├─ linux/riscv64                           1de5eb4a9a67           0B             0B
    └─ linux/s390x                             fe0dcdd1f783           0B             0B

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-04-11 14:58:23 +02:00

485 lines
11 KiB
Go

// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.22
package image
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"github.com/containerd/platforms"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/tui"
"github.com/docker/docker/api/types/filters"
imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
"github.com/morikuni/aec"
"github.com/opencontainers/go-digest"
)
type treeOptions struct {
all bool
filters filters.Args
}
type treeView struct {
images []topImage
// imageSpacing indicates whether there should be extra spacing between images.
imageSpacing bool
}
func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error {
images, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{
All: opts.all,
Filters: opts.filters,
Manifests: true,
})
if err != nil {
return err
}
if !opts.all {
images = slices.DeleteFunc(images, isDangling)
}
view := treeView{
images: make([]topImage, 0, len(images)),
}
attested := make(map[digest.Digest]bool)
for _, img := range images {
details := imageDetails{
ID: img.ID,
DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3),
InUse: img.Containers > 0,
}
var totalContent int64
children := make([]subImage, 0, len(img.Manifests))
for _, im := range img.Manifests {
totalContent += im.Size.Content
if im.Kind == imagetypes.ManifestKindAttestation {
attested[im.AttestationData.For] = true
continue
}
if im.Kind != imagetypes.ManifestKindImage {
continue
}
sub := subImage{
Platform: platforms.Format(im.ImageData.Platform),
Available: im.Available,
Details: imageDetails{
ID: im.ID,
DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3),
InUse: len(im.ImageData.Containers) > 0,
ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3),
},
}
if sub.Details.InUse {
// Mark top-level parent image as used if any of its subimages are used.
details.InUse = true
}
children = append(children, sub)
// Add extra spacing between images if there's at least one entry with children.
view.imageSpacing = true
}
details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3)
view.images = append(view.images, topImage{
Names: img.RepoTags,
Details: details,
Children: children,
created: img.Created,
})
}
sort.Slice(view.images, func(i, j int) bool {
return view.images[i].created > view.images[j].created
})
return printImageTree(dockerCLI, view)
}
type imageDetails struct {
ID string
DiskUsage string
InUse bool
ContentSize string
}
type topImage struct {
Names []string
Details imageDetails
Children []subImage
created int64
}
type subImage struct {
Platform string
Available bool
Details imageDetails
}
const columnSpacing = 3
var chipInUse = imageChip{
letter: "U",
desc: "In Use",
fg: 0,
bg: 14,
check: func(d *imageDetails) bool { return d.InUse },
}
var chipPlaceholder = tui.Str{
Plain: " ",
Fancy: " ",
}
type imageChip struct {
desc string
fg, bg int
letter string
check func(*imageDetails) bool
}
func (c imageChip) String(isTerm bool) string {
return tui.Str{
Plain: c.letter,
Fancy: tui.Chip(c.fg, c.bg, " "+c.letter+" "),
}.String(isTerm)
}
var allChips = []imageChip{
chipInUse,
}
func getPossibleChips(view treeView) (chips []imageChip) {
remaining := make([]imageChip, len(allChips))
copy(remaining, allChips)
var possible []imageChip
for _, img := range view.images {
for _, c := range img.Children {
for idx := len(remaining) - 1; idx >= 0; idx-- {
chip := remaining[idx]
if chip.check(&c.Details) {
possible = append(possible, chip)
remaining = append(remaining[:idx], remaining[idx+1:]...)
}
}
}
}
return possible
}
func printImageTree(dockerCLI command.Cli, view treeView) error {
out := tui.NewOutput(dockerCLI.Out())
_, width := out.GetTtySize()
if width == 0 {
width = 80
}
if width < 20 {
width = 20
}
topNameColor := out.Color(aec.NewBuilder(aec.BlueF, aec.Bold).ANSI)
normalColor := out.Color(tui.ColorSecondary)
untaggedColor := out.Color(tui.ColorTertiary)
isTerm := out.IsTerminal()
out.PrintlnWithColor(tui.ColorWarning, "WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")
out.Println(generateLegend(out, width))
out.Println()
possibleChips := getPossibleChips(view)
columns := []imgColumn{
{
Title: "Image",
Align: alignLeft,
Width: 0,
},
{
Title: "ID",
Align: alignLeft,
Width: 12,
DetailsValue: func(d *imageDetails) string {
return stringid.TruncateID(d.ID)
},
},
{
Title: "Disk usage",
Align: alignRight,
Width: 10,
DetailsValue: func(d *imageDetails) string {
return d.DiskUsage
},
},
{
Title: "Content size",
Align: alignRight,
Width: 12,
DetailsValue: func(d *imageDetails) string {
return d.ContentSize
},
},
{
Title: "Extra",
Align: alignLeft,
Width: func() int {
maxChipsWidth := 0
for _, chip := range possibleChips {
s := chip.String(isTerm)
l := tui.Width(s)
maxChipsWidth += l
}
le := len("Extra")
if le > maxChipsWidth {
return le
}
return maxChipsWidth
}(),
Color: &tui.ColorNone,
DetailsValue: func(d *imageDetails) string {
var out string
for _, chip := range possibleChips {
if chip.check(d) {
out += chip.String(isTerm)
} else {
out += chipPlaceholder.String(isTerm)
}
}
return out
},
},
}
columns = adjustColumns(width, columns, view.images)
// Print columns
for i, h := range columns {
if i > 0 {
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
}
_, _ = fmt.Fprint(out, h.Print(tui.ColorTitle, strings.ToUpper(h.Title)))
}
_, _ = fmt.Fprintln(out)
// Print images
for _, img := range view.images {
printNames(out, columns, img, topNameColor, untaggedColor)
printDetails(out, columns, normalColor, img.Details)
if len(img.Children) > 0 || view.imageSpacing {
_, _ = fmt.Fprintln(out)
}
printChildren(out, columns, img, normalColor)
_, _ = fmt.Fprintln(out)
}
return nil
}
// adjustColumns adjusts the width of the first column to maximize the space
// available for image names and removes any columns that would be too narrow
// to display their content.
func adjustColumns(width uint, columns []imgColumn, images []topImage) []imgColumn {
nameWidth := int(width)
for idx, h := range columns {
if h.Width == 0 {
continue
}
d := h.Width
if idx > 0 {
d += columnSpacing
}
// If the first column gets too short, remove remaining columns
if nameWidth-d < 12 {
columns = columns[:idx]
break
}
nameWidth -= d
}
// Try to make the first column as narrow as possible
widest := widestFirstColumnValue(columns, images)
if nameWidth > widest {
nameWidth = widest
}
columns[0].Width = nameWidth
return columns
}
func generateLegend(out tui.Output, width uint) string {
var legend string
legend += out.Sprint(tui.InfoHeader)
for idx, chip := range allChips {
legend += " " + out.Sprint(chip) + " " + chip.desc
if idx < len(allChips)-1 {
legend += " |"
}
}
legend += " "
r := int(width) - tui.Width(legend)
if r < 0 {
r = 0
}
legend = strings.Repeat(" ", r) + legend
return legend
}
func printDetails(out tui.Output, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
for _, h := range headers {
if h.DetailsValue == nil {
continue
}
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
clr := defaultColor
if h.Color != nil {
clr = *h.Color
}
val := h.DetailsValue(&details)
_, _ = fmt.Fprint(out, h.Print(clr, val))
}
}
func printChildren(out tui.Output, headers []imgColumn, img topImage, normalColor aec.ANSI) {
for idx, sub := range img.Children {
clr := normalColor
if !sub.Available {
clr = normalColor.With(aec.Faint)
}
text := sub.Platform
if idx != len(img.Children)-1 {
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+text))
} else {
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+text))
}
printDetails(out, headers, clr, sub.Details)
_, _ = fmt.Fprintln(out, "")
}
}
func printNames(out tui.Output, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) {
if len(img.Names) == 0 {
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
}
// TODO: Replace with namesLongestToShortest := slices.SortedFunc(slices.Values(img.Names))
// once we move to Go 1.23.
namesLongestToShortest := make([]string, len(img.Names))
copy(namesLongestToShortest, img.Names)
sort.Slice(namesLongestToShortest, func(i, j int) bool {
return len(namesLongestToShortest[i]) > len(namesLongestToShortest[j])
})
for nameIdx, name := range namesLongestToShortest {
// Don't limit first names to the column width because only the last
// name will be printed alongside other columns.
if nameIdx < len(img.Names)-1 {
_, fullWidth := out.GetTtySize()
_, _ = fmt.Fprintln(out, color.Apply(tui.Ellipsis(name, int(fullWidth))))
} else {
_, _ = fmt.Fprint(out, headers[0].Print(color, name))
}
}
}
type alignment int
const (
alignLeft alignment = iota
alignCenter
alignRight
)
type imgColumn struct {
Title string
Width int
Align alignment
DetailsValue func(*imageDetails) string
Color *aec.ANSI
}
func (h imgColumn) Print(clr aec.ANSI, s string) string {
switch h.Align {
case alignCenter:
return h.PrintC(clr, s)
case alignRight:
return h.PrintR(clr, s)
case alignLeft:
}
return h.PrintL(clr, s)
}
func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
ln := tui.Width(s)
if ln > h.Width {
return clr.Apply(tui.Ellipsis(s, h.Width))
}
fill := h.Width - ln
l := fill / 2
r := fill - l
return strings.Repeat(" ", l) + clr.Apply(s) + strings.Repeat(" ", r)
}
func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
ln := tui.Width(s)
if ln > h.Width {
return clr.Apply(tui.Ellipsis(s, h.Width))
}
return clr.Apply(s) + strings.Repeat(" ", h.Width-ln)
}
func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
ln := tui.Width(s)
if ln > h.Width {
return clr.Apply(tui.Ellipsis(s, h.Width))
}
return strings.Repeat(" ", h.Width-ln) + clr.Apply(s)
}
// widestFirstColumnValue calculates the width needed to fully display the image names and platforms.
func widestFirstColumnValue(headers []imgColumn, images []topImage) int {
width := len(headers[0].Title)
for _, img := range images {
for _, name := range img.Names {
if len(name) > width {
width = len(name)
}
}
for _, sub := range img.Children {
pl := len(sub.Platform) + len("└─ ")
if pl > width {
width = pl
}
}
}
return width
}