image/list: Add `--tree` flag

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
(cherry picked from commit be11b74ee9)
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Paweł Gronowski 2024-03-28 17:49:04 +01:00
parent f90dc28f1e
commit 99b647cfca
No known key found for this signature in database
GPG Key ID: B85EFCFE26DEF92A
4 changed files with 340 additions and 0 deletions

View File

@ -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,

312
cli/command/image/tree.go Normal file
View File

@ -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
}

View File

@ -17,6 +17,7 @@ List images
| [`--format`](#format) | `string` | | Format output using a custom template:<br>'table': Print output in table format with column headers (default)<br>'table TEMPLATE': Print output in table format using the given Go template<br>'json': Print in JSON format<br>'TEMPLATE': Print output using the given Go template.<br>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) |
<!---MARKER_GEN_END-->

View File

@ -17,6 +17,7 @@ List images
| `--format` | `string` | | Format output using a custom template:<br>'table': Print output in table format with column headers (default)<br>'table TEMPLATE': Print output in table format using the given Go template<br>'json': Print in JSON format<br>'TEMPLATE': Print output using the given Go template.<br>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) |
<!---MARKER_GEN_END-->