Sebastiaan van Stijn 1d768f8983
update go:build tags to go1.23 to align with vendor.mod
Go maintainers started to unconditionally update the minimum go version
for golang.org/x/ dependencies to go1.23, which means that we'll no longer
be able to support any version below that when updating those dependencies;

> all: upgrade go directive to at least 1.23.0 [generated]
>
> By now Go 1.24.0 has been released, and Go 1.22 is no longer supported
> per the Go Release Policy (https://go.dev/doc/devel/release#policy).
>
> For golang/go#69095.

This updates our minimum version to go1.23, as we won't be able to maintain
compatibility with older versions because of the above.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-04-17 10:43:47 +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.23
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
}