From be11b74ee95632d9c99e5daba3f795c00db60963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 28 Mar 2024 17:49:04 +0100 Subject: [PATCH 1/7] image/list: Add `--tree` flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/list.go | 26 +++ cli/command/image/tree.go | 312 +++++++++++++++++++++++++ docs/reference/commandline/image_ls.md | 1 + docs/reference/commandline/images.md | 1 + 4 files changed, 340 insertions(+) create mode 100644 cli/command/image/tree.go diff --git a/cli/command/image/list.go b/cli/command/image/list.go index a691efed453b..cfe73c5adeb3 100644 --- a/cli/command/image/list.go +++ b/cli/command/image/list.go @@ -2,6 +2,7 @@ package image import ( "context" + "errors" "fmt" "io" @@ -24,6 +25,7 @@ type imagesOptions struct { format string filter opts.FilterOpt calledAs string + tree bool } // NewImagesCommand creates a new `docker images` command @@ -59,6 +61,10 @@ func NewImagesCommand(dockerCLI command.Cli) *cobra.Command { flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp) flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided") + flags.BoolVar(&options.tree, "tree", false, "List multi-platform images as a tree (EXPERIMENTAL)") + flags.SetAnnotation("tree", "version", []string{"1.47"}) + flags.SetAnnotation("tree", "experimentalCLI", nil) + return cmd } @@ -75,6 +81,26 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions filters.Add("reference", options.matchName) } + if options.tree { + if options.quiet { + return errors.New("--quiet is not yet supported with --tree") + } + if options.noTrunc { + return errors.New("--no-trunc is not yet supported with --tree") + } + if options.showDigests { + return errors.New("--show-digest is not yet supported with --tree") + } + if options.format != "" { + return errors.New("--format is not yet supported with --tree") + } + + return runTree(ctx, dockerCLI, treeOptions{ + all: options.all, + filters: filters, + }) + } + images, err := dockerCLI.Client().ImageList(ctx, image.ListOptions{ All: options.all, Filters: filters, diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go new file mode 100644 index 000000000000..a4f0761e92b1 --- /dev/null +++ b/cli/command/image/tree.go @@ -0,0 +1,312 @@ +package image + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + "github.com/containerd/platforms" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/streams" + "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" +) + +type treeOptions struct { + all bool + filters filters.Args +} + +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 + } + + view := make([]topImage, 0, len(images)) + for _, img := range images { + details := imageDetails{ + ID: img.ID, + DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), + Used: img.Containers > 0, + } + + children := make([]subImage, 0, len(img.Manifests)) + for _, im := range img.Manifests { + 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), + Used: len(im.ImageData.Containers) > 0, + }, + } + + children = append(children, sub) + } + + view = append(view, topImage{ + Names: img.RepoTags, + Details: details, + Children: children, + }) + } + + return printImageTree(dockerCLI, view) +} + +type imageDetails struct { + ID string + DiskUsage string + Used bool +} + +type topImage struct { + Names []string + Details imageDetails + Children []subImage +} + +type subImage struct { + Platform string + Available bool + Details imageDetails +} + +const columnSpacing = 3 + +func printImageTree(dockerCLI command.Cli, images []topImage) error { + out := dockerCLI.Out() + _, width := out.GetTtySize() + if width == 0 { + width = 80 + } + if width < 20 { + width = 20 + } + + headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI + topNameColor := aec.NewBuilder(aec.BlueF, aec.Underline, aec.Bold).ANSI + normalColor := aec.NewBuilder(aec.DefaultF).ANSI + greenColor := aec.NewBuilder(aec.GreenF).ANSI + if !out.IsTerminal() { + headerColor = noColor{} + topNameColor = noColor{} + normalColor = noColor{} + greenColor = noColor{} + } + + columns := []imgColumn{ + {Title: "Image", Width: 0, Left: true}, + { + Title: "ID", + Width: 12, + DetailsValue: func(d *imageDetails) string { + return stringid.TruncateID(d.ID) + }, + }, + { + Title: "Disk usage", + Width: 10, + DetailsValue: func(d *imageDetails) string { + return d.DiskUsage + }, + }, + { + Title: "Used", + Width: 4, + Color: &greenColor, + DetailsValue: func(d *imageDetails) string { + if d.Used { + return "✔" + } + return " " + }, + }, + } + + 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 + + // Print columns + for i, h := range columns { + if i > 0 { + _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) + } + + _, _ = fmt.Fprint(out, h.Print(headerColor, h.Title)) + } + + _, _ = fmt.Fprintln(out) + + // Print images + for idx, img := range images { + if idx != 0 { + _, _ = fmt.Fprintln(out, "") + } + + printNames(out, columns, img, topNameColor) + printDetails(out, columns, normalColor, img.Details) + printChildren(out, columns, img, normalColor) + } + + return nil +} + +func printDetails(out *streams.Out, 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)) + } + fmt.Printf("\n") +} + +func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalColor aec.ANSI) { + for idx, sub := range img.Children { + clr := normalColor + if !sub.Available { + clr = normalColor.With(aec.Faint) + } + + if idx != len(img.Children)-1 { + _, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform)) + } else { + _, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform)) + } + + printDetails(out, headers, clr, sub.Details) + } +} + +func printNames(out *streams.Out, headers []imgColumn, img topImage, color aec.ANSI) { + for nameIdx, name := range img.Names { + if nameIdx != 0 { + _, _ = fmt.Fprintln(out, "") + } + _, _ = fmt.Fprint(out, headers[0].Print(color, name)) + } +} + +type imgColumn struct { + Title string + Width int + Left bool + + DetailsValue func(*imageDetails) string + Color *aec.ANSI +} + +func truncateRunes(s string, length int) string { + runes := []rune(s) + if len(runes) > length { + return string(runes[:length-3]) + "..." + } + return s +} + +func (h imgColumn) Print(clr aec.ANSI, s string) (out string) { + if h.Left { + return h.PrintL(clr, s) + } + return h.PrintC(clr, s) +} + +func (h imgColumn) PrintC(clr aec.ANSI, s string) (out string) { + ln := utf8.RuneCountInString(s) + + if ln > h.Width { + return clr.Apply(truncateRunes(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 := utf8.RuneCountInString(s) + if ln > h.Width { + return clr.Apply(truncateRunes(s, h.Width)) + } + + return clr.Apply(s) + strings.Repeat(" ", h.Width-ln) +} + +type noColor struct{} + +func (a noColor) With(ansi ...aec.ANSI) aec.ANSI { + return aec.NewBuilder(ansi...).ANSI +} + +func (a noColor) Apply(s string) string { + return s +} + +func (a noColor) String() string { + return "" +} + +// 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 +} diff --git a/docs/reference/commandline/image_ls.md b/docs/reference/commandline/image_ls.md index 3365c29c6b45..29174f171b46 100644 --- a/docs/reference/commandline/image_ls.md +++ b/docs/reference/commandline/image_ls.md @@ -17,6 +17,7 @@ List images | [`--format`](#format) | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates | | [`--no-trunc`](#no-trunc) | `bool` | | Don't truncate output | | `-q`, `--quiet` | `bool` | | Only show image IDs | +| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) | diff --git a/docs/reference/commandline/images.md b/docs/reference/commandline/images.md index 1f7b3b5a4da0..8d615ec21b10 100644 --- a/docs/reference/commandline/images.md +++ b/docs/reference/commandline/images.md @@ -17,6 +17,7 @@ List images | `--format` | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates | | `--no-trunc` | `bool` | | Don't truncate output | | `-q`, `--quiet` | `bool` | | Only show image IDs | +| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) | From ea8aafcd9e695b9e692bfea47ea568b78daed761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 14 Aug 2024 13:46:45 +0200 Subject: [PATCH 2/7] cli/tree: Add `Content size` column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/tree.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index a4f0761e92b1..64d32cd0e3a7 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -39,25 +39,30 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error Used: img.Containers > 0, } + var totalContent int64 children := make([]subImage, 0, len(img.Manifests)) for _, im := range img.Manifests { if im.Kind != imagetypes.ManifestKindImage { continue } + im := im sub := subImage{ Platform: platforms.Format(im.ImageData.Platform), Available: im.Available, Details: imageDetails{ - ID: im.ID, - DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3), - Used: len(im.ImageData.Containers) > 0, + ID: im.ID, + DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3), + Used: len(im.ImageData.Containers) > 0, + ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3), }, } + totalContent += im.Size.Content children = append(children, sub) } + details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) view = append(view, topImage{ Names: img.RepoTags, Details: details, @@ -69,9 +74,10 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error } type imageDetails struct { - ID string - DiskUsage string - Used bool + ID string + DiskUsage string + Used bool + ContentSize string } type topImage struct { @@ -125,6 +131,13 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { return d.DiskUsage }, }, + { + Title: "Content size", + Width: 12, + DetailsValue: func(d *imageDetails) string { + return d.ContentSize + }, + }, { Title: "Used", Width: 4, From 18ab78882c43e0be274a6051de9a1cf0f46d3def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 16 Aug 2024 11:24:53 +0200 Subject: [PATCH 3/7] list/tree: Align number right, text left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/tree.go | 48 +++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index 64d32cd0e3a7..03930b2f11d2 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -58,6 +58,11 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error }, } + if sub.Details.Used { + // Mark top-level parent image as used if any of its subimages are used. + details.Used = true + } + totalContent += im.Size.Content children = append(children, sub) } @@ -116,9 +121,14 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { } columns := []imgColumn{ - {Title: "Image", Width: 0, Left: true}, + { + Title: "Image", + Align: alignLeft, + Width: 0, + }, { Title: "ID", + Align: alignLeft, Width: 12, DetailsValue: func(d *imageDetails) string { return stringid.TruncateID(d.ID) @@ -126,6 +136,7 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { }, { Title: "Disk usage", + Align: alignRight, Width: 10, DetailsValue: func(d *imageDetails) string { return d.DiskUsage @@ -133,6 +144,7 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { }, { Title: "Content size", + Align: alignRight, Width: 12, DetailsValue: func(d *imageDetails) string { return d.ContentSize @@ -140,6 +152,7 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { }, { Title: "Used", + Align: alignCenter, Width: 4, Color: &greenColor, DetailsValue: func(d *imageDetails) string { @@ -243,10 +256,18 @@ func printNames(out *streams.Out, headers []imgColumn, img topImage, color aec.A } } +type alignment int + +const ( + alignLeft alignment = iota + alignCenter + alignRight +) + type imgColumn struct { Title string Width int - Left bool + Align alignment DetailsValue func(*imageDetails) string Color *aec.ANSI @@ -260,14 +281,18 @@ func truncateRunes(s string, length int) string { return s } -func (h imgColumn) Print(clr aec.ANSI, s string) (out string) { - if h.Left { - return h.PrintL(clr, s) +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.PrintC(clr, s) + return h.PrintL(clr, s) } -func (h imgColumn) PrintC(clr aec.ANSI, s string) (out string) { +func (h imgColumn) PrintC(clr aec.ANSI, s string) string { ln := utf8.RuneCountInString(s) if ln > h.Width { @@ -291,6 +316,15 @@ func (h imgColumn) PrintL(clr aec.ANSI, s string) string { return clr.Apply(s) + strings.Repeat(" ", h.Width-ln) } +func (h imgColumn) PrintR(clr aec.ANSI, s string) string { + ln := utf8.RuneCountInString(s) + if ln > h.Width { + return clr.Apply(truncateRunes(s, h.Width)) + } + + return strings.Repeat(" ", h.Width-ln) + clr.Apply(s) +} + type noColor struct{} func (a noColor) With(ansi ...aec.ANSI) aec.ANSI { From b1a08f78417849ae4d51049f1102e3b1789bac35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 16 Aug 2024 11:32:37 +0200 Subject: [PATCH 4/7] list/tree: Sort by created date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/tree.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index 03930b2f11d2..868c68582bbc 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -3,6 +3,7 @@ package image import ( "context" "fmt" + "sort" "strings" "unicode/utf8" @@ -68,13 +69,19 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error } details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) + view = append(view, topImage{ Names: img.RepoTags, Details: details, Children: children, + created: img.Created, }) } + sort.Slice(view, func(i, j int) bool { + return view[i].created > view[j].created + }) + return printImageTree(dockerCLI, view) } @@ -89,6 +96,8 @@ type topImage struct { Names []string Details imageDetails Children []subImage + + created int64 } type subImage struct { From d417d0668264726cd16592ddc20060f8db5e9961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 16 Aug 2024 13:50:03 +0200 Subject: [PATCH 5/7] list/tree: Add an experimental warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/tree.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index 868c68582bbc..ffaa70711ab0 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -118,6 +118,7 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { width = 20 } + warningColor := aec.LightYellowF headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI topNameColor := aec.NewBuilder(aec.BlueF, aec.Underline, aec.Bold).ANSI normalColor := aec.NewBuilder(aec.DefaultF).ANSI @@ -127,8 +128,11 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { topNameColor = noColor{} normalColor = noColor{} greenColor = noColor{} + warningColor = noColor{} } + _, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")) + columns := []imgColumn{ { Title: "Image", From 0242a1e3c60ad25bfac73a45cf0fb00a8ddd1774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 16 Aug 2024 13:48:26 +0200 Subject: [PATCH 6/7] list/tree: Capitalize column headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/tree.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index ffaa70711ab0..0c114641aa1f 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -207,7 +207,7 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) } - _, _ = fmt.Fprint(out, h.Print(headerColor, h.Title)) + _, _ = fmt.Fprint(out, h.Print(headerColor, strings.ToUpper(h.Title))) } _, _ = fmt.Fprintln(out) From a9b78da546ccfa90b420356eda4b70074881281d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 16 Aug 2024 13:48:49 +0200 Subject: [PATCH 7/7] list/tree: Add spacing before the content and first image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- cli/command/image/tree.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index 0c114641aa1f..a034ca5260c7 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -132,6 +132,7 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { } _, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")) + _, _ = fmt.Fprintln(out, "") columns := []imgColumn{ { @@ -213,11 +214,8 @@ func printImageTree(dockerCLI command.Cli, images []topImage) error { _, _ = fmt.Fprintln(out) // Print images - for idx, img := range images { - if idx != 0 { - _, _ = fmt.Fprintln(out, "") - } - + for _, img := range images { + _, _ = fmt.Fprintln(out, "") printNames(out, columns, img, topNameColor) printDetails(out, columns, normalColor, img.Details) printChildren(out, columns, img, normalColor)