From a51e3bc72793f9d45aa3e7df6f8911b4d1c92e39 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] 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 | 25 +++ cli/command/image/tree.go | 247 +++++++++++++++++++++++++ docs/reference/commandline/image_ls.md | 1 + docs/reference/commandline/images.md | 1 + 4 files changed, 274 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..d836faa92a07 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,9 @@ 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 tree [experimental, behavior may change]") + flags.SetAnnotation("tree", "api", []string{"1.46"}) + return cmd } @@ -75,6 +80,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..3efb24164439 --- /dev/null +++ b/cli/command/image/tree.go @@ -0,0 +1,247 @@ +package image + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + "github.com/docker/cli/cli/command" + "github.com/morikuni/aec" + + "github.com/containerd/platforms" + "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" +) + +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, + ContainerCount: true, + Filters: opts.filters, + Manifests: true, + }) + if err != nil { + return err + } + + var view []topImage + for _, img := range images { + details := imageDetails{ + ID: img.ID, + DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), + Used: img.Containers > 0, + } + + var children []subImage + for _, im := range img.Manifests { + if im.Kind != imagetypes.ImageManifestKindImage { + continue + } + + imgData := im.ImageData + platform := imgData.Platform + + sub := subImage{ + Platform: platforms.Format(platform), + Available: im.Available, + Details: imageDetails{ + ID: im.ID, + DiskUsage: units.HumanSizeWithPrecision(float64(im.ContentSize+imgData.UnpackedSize), 3), + Used: len(imgData.Containers) > 0, + }, + } + + children = append(children, sub) + } + + for _, tag := range img.RepoTags { + view = append(view, topImage{ + Name: tag, + Details: details, + Children: children, + }) + } + } + + return printImageTree(dockerCLI, view) +} + +type imageDetails struct { + ID string + DiskUsage string + Used bool +} + +type topImage struct { + Name string + Details imageDetails + Children []subImage +} + +type subImage struct { + Platform string + Available bool + Details imageDetails +} + +func printImageTree(dockerCLI command.Cli, images []topImage) error { + out := dockerCLI.Out() + _, width := out.GetTtySize() + + headers := []header{ + {Title: "Image", Width: 0, Left: true}, + {Title: "ID", Width: 12}, + {Title: "Disk use", Width: 8}, + {Title: "Used", Width: 4}, + } + + const spacing = 3 + nameWidth := int(width) + for _, h := range headers { + if h.Width == 0 { + continue + } + nameWidth -= h.Width + nameWidth -= spacing + } + + maxImageName := len(headers[0].Title) + for _, img := range images { + if len(img.Name) > maxImageName { + maxImageName = len(img.Name) + } + for _, sub := range img.Children { + if len(sub.Platform) > maxImageName { + maxImageName = len(sub.Platform) + } + } + } + + if nameWidth > maxImageName+spacing { + nameWidth = maxImageName + spacing + } + + if nameWidth < 0 { + headers = headers[:1] + nameWidth = int(width) + } + headers[0].Width = nameWidth + + headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI + + // Print headers + for i, h := range headers { + if i > 0 { + _, _ = fmt.Fprint(out, strings.Repeat(" ", spacing)) + } + + _, _ = fmt.Fprint(out, h.PrintC(headerColor, h.Title)) + } + + _, _ = fmt.Fprintln(out) + + topNameColor := aec.NewBuilder(aec.BlueF, aec.Underline, aec.Bold).ANSI + normalColor := aec.NewBuilder(aec.DefaultF).ANSI + normalFaintedColor := aec.NewBuilder(aec.DefaultF).Faint().ANSI + greenColor := aec.NewBuilder(aec.GreenF).ANSI + + printDetails := func(clr aec.ANSI, details imageDetails) { + truncID := stringid.TruncateID(details.ID) + fmt.Fprint(out, headers[1].Print(clr, truncID)) + fmt.Fprint(out, strings.Repeat(" ", spacing)) + + fmt.Fprint(out, headers[2].Print(clr, details.DiskUsage)) + fmt.Fprint(out, strings.Repeat(" ", spacing)) + + if details.Used { + fmt.Fprint(out, headers[3].Print(greenColor, " ✔ ️")) + } else { + fmt.Fprint(out, headers[3].Print(clr, " ")) + } + } + + // Print images + for _, img := range images { + fmt.Fprint(out, headers[0].Print(topNameColor, img.Name)) + fmt.Fprint(out, strings.Repeat(" ", spacing)) + + printDetails(normalColor, img.Details) + + _, _ = fmt.Fprintln(out, "") + for idx, sub := range img.Children { + clr := normalColor + if !sub.Available { + clr = normalFaintedColor + } + + 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)) + } + + fmt.Fprint(out, strings.Repeat(" ", spacing)) + printDetails(clr, sub.Details) + + fmt.Fprintln(out, "") + } + } + + return nil +} + +type header struct { + Title string + Width int + Left bool +} + +func truncateRunes(s string, length int) string { + runes := []rune(s) + if len(runes) > length { + return string(runes[:length]) + } + return s +} + +func (h header) Print(clr aec.ANSI, s string) (out string) { + if h.Left { + return h.PrintL(clr, s) + } + return h.PrintC(clr, s) +} + +func (h header) PrintC(clr aec.ANSI, s string) (out string) { + ln := utf8.RuneCountInString(s) + if h.Left { + return h.PrintL(clr, 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 header) 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) +} diff --git a/docs/reference/commandline/image_ls.md b/docs/reference/commandline/image_ls.md index 3365c29c6b45..8960af1f3a91 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 tree [experimental, behavior may change] | diff --git a/docs/reference/commandline/images.md b/docs/reference/commandline/images.md index 1f7b3b5a4da0..4d004c00a29d 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 tree [experimental, behavior may change] |