Skip to content

Commit 9b8a4d9

Browse files
committed
top: add support for --layers
Get top files for one or more layers specified via digest, partial or complete. Errors out if the passed layers don't match any layer in the image, or it matches more than one layers. Merges the output when more than one layer is passed. * add unit tests Signed-off-by: Danish Prakash <[email protected]>
1 parent c2002fd commit 9b8a4d9

File tree

7 files changed

+457
-34
lines changed

7 files changed

+457
-34
lines changed

.github/workflows/ci.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,7 @@ jobs:
4444
- name: build
4545
run: make binaries
4646
working-directory: .
47+
48+
- name: test
49+
run: make unit-tests
50+
working-directory: .

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
.PHONY: binaries, vendor
1+
.PHONY: binaries, vendor, unit-tests
22

33
# Build the binaries
44
binaries:
55
go build -o bin/skiff ./cmd/skiff
66

7+
# Run tests
8+
unit-tests:
9+
go test -v ./cmd/skiff/...
10+
711
vendor:
812
go mod tidy
913
go mod vendor

cmd/skiff/layers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func ShowLayerUsage(ctx context.Context, sysCtx *types.SystemContext, uri string
2222
}
2323

2424
res := ""
25-
if layers != nil && len(layers) > 0 {
25+
if len(layers) > 0 {
2626
if len(inspect.LayersData) != len(layers) {
2727
return "", fmt.Errorf(
2828
"internal error: image inspect returned %d layers, storage returned %d layers",

cmd/skiff/top.go

Lines changed: 113 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import (
66
"context"
77
"fmt"
88
"io"
9+
"os"
910
"path/filepath"
1011
"slices"
1112
"strconv"
1213
"strings"
14+
"text/tabwriter"
1315

1416
"github.com/containers/image/v5/pkg/blobinfocache/none"
1517
"github.com/containers/image/v5/pkg/compression"
@@ -25,6 +27,16 @@ var topCommand = cli.Command{
2527
Flags: []cli.Flag{
2628
&cli.BoolFlag{Name: "include-pseudo", Usage: "Include pseudo-filesystems (/dev, /proc, /sys)"},
2729
&cli.BoolFlag{Name: "follow-symlinks", Usage: "Follow symbolic links"},
30+
&cli.BoolFlag{
31+
Name: "human-readable",
32+
Usage: "Show file sizes in human readable format",
33+
Aliases: []string{"h"},
34+
},
35+
&cli.StringSliceFlag{
36+
Name: "layer",
37+
Usage: "Filter results to specific layer(s) by SHA256 digest. If not specified, all layers are included (not an empty result).",
38+
Aliases: []string{"l"},
39+
},
2840
},
2941
Arguments: []cli.Argument{
3042
&cli.StringArg{Name: "image", UsageText: "Container image ref"},
@@ -35,17 +47,25 @@ var topCommand = cli.Command{
3547
return fmt.Errorf("image URL is required")
3648
}
3749

50+
humanReadable := c.Bool("human-readable")
51+
layers := c.StringSlice("layer")
52+
if c.IsSet("layer") && len(layers) == 0 {
53+
return fmt.Errorf("--layer flag provided but no layer digest specified; please provide at least one layer digest")
54+
}
55+
3856
sysCtx := types.SystemContext{}
39-
return analyzeLayers(image, ctx, &sysCtx)
57+
58+
return analyzeLayers(ctx, &sysCtx, image, layers, humanReadable)
4059
},
4160
}
4261

4362
const defaultFileLimit = 10
4463

4564
type FileInfo struct {
46-
Path string
47-
Size int64
48-
Layer string // layer ID this file belongs to
65+
Path string
66+
Size int64
67+
HumanReadableSize string
68+
Layer string // layer ID this file belongs to
4969
}
5070

5171
type FileHeap []FileInfo
@@ -66,9 +86,73 @@ func (h *FileHeap) Pop() interface{} {
6686
return item
6787
}
6888

89+
// HumanReadableSize converts a byte count to a human readable string
90+
// From https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/
91+
func HumanReadableSize(b int64) string {
92+
const unit = 1000
93+
if b < unit {
94+
return fmt.Sprintf("%d B", b)
95+
}
96+
div, exp := int64(unit), 0
97+
for n := b / unit; n >= unit; n /= unit {
98+
div *= unit
99+
exp++
100+
}
101+
return fmt.Sprintf("%.1f %cB",
102+
float64(b)/float64(div), "kMGTPE"[exp])
103+
}
104+
105+
// getFilteredLayers returns only the layers that needs to be
106+
// processed/extracted e.g. after user specifies specific layer(s)
107+
// using --layer, we shouldn't be processing all the layers.
108+
// If layers is empty, returns all layers in the image.
109+
func getFilteredLayers(img types.Image, layers []string) ([]types.BlobInfo, error) {
110+
allLayers := img.LayerInfos()
111+
112+
if len(layers) == 0 {
113+
return allLayers, nil // No filtering needed
114+
}
115+
116+
// Build a map for efficient layer lookup
117+
layerMap := make(map[string]types.BlobInfo)
118+
for _, layer := range allLayers {
119+
layerMap[layer.Digest.Encoded()] = layer
120+
}
121+
122+
var filteredLayers []types.BlobInfo
123+
seenLayers := make(map[string]bool) // Track which layers we've already added
124+
125+
for _, filter := range layers {
126+
var matchedLayersDigests []string
127+
for layerDigest := range layerMap {
128+
if layerDigest == filter || strings.HasPrefix(layerDigest, filter) {
129+
matchedLayersDigests = append(matchedLayersDigests, layerDigest)
130+
}
131+
}
132+
133+
if len(matchedLayersDigests) == 0 {
134+
return nil, fmt.Errorf("layer %s not found in image", filter)
135+
}
136+
if len(matchedLayersDigests) > 1 {
137+
return nil, fmt.Errorf("multiple layers match shortened digest %s", filter)
138+
}
139+
140+
matchedLayerDigest := matchedLayersDigests[0]
141+
layer := layerMap[matchedLayerDigest]
142+
143+
// Only add if we haven't seen this layer before
144+
if !seenLayers[layer.Digest.String()] {
145+
filteredLayers = append(filteredLayers, layer)
146+
seenLayers[layer.Digest.String()] = true
147+
}
148+
}
149+
150+
return filteredLayers, nil
151+
}
152+
69153
// analyzeLayers fetches layers for a given image reference
70154
// reads the associated layer archives and lists file info
71-
func analyzeLayers(uri string, ctx context.Context, sysCtx *types.SystemContext) error {
155+
func analyzeLayers(ctx context.Context, sysCtx *types.SystemContext, uri string, layers []string, humanReadable bool) error {
72156
img, _, err := skiff.ImageAndLayersFromURI(ctx, sysCtx, uri)
73157
if err != nil {
74158
return err
@@ -83,9 +167,7 @@ func analyzeLayers(uri string, ctx context.Context, sysCtx *types.SystemContext)
83167
h := &FileHeap{}
84168
heap.Init(h)
85169

86-
files := make([]FileInfo, h.Len())
87-
88-
layerInfos, err := skiff.BlobInfoFromImage(img, ctx, sysCtx)
170+
layerInfos, err := getFilteredLayers(img, layers)
89171
if err != nil {
90172
return err
91173
}
@@ -113,48 +195,49 @@ func analyzeLayers(uri string, ctx context.Context, sysCtx *types.SystemContext)
113195
return fmt.Errorf("failed to read tar header: %w", err)
114196
}
115197

116-
// TODO: follow symlinks
198+
// TODO(danishprakash): follow symlinks
117199
// if hdr.Typeflag == tar.TypeSymlink
118200

119201
path, err := filepath.Abs(filepath.Join("/", hdr.Name))
120202
if err != nil {
121-
// TODO: perhaps just log and not error out
122-
return fmt.Errorf("error generating absolute representation of path: %w", err)
203+
// Log the error but continue processing other files
204+
fmt.Fprintf(os.Stderr, "warning: error generating absolute representation of path %s: %v\n", hdr.Name, err)
205+
continue
123206
}
124207

125208
if hdr.Typeflag == tar.TypeReg {
126-
heap.Push(h, FileInfo{Path: path, Size: hdr.Size, Layer: layer.Digest.Encoded()})
209+
heap.Push(h, FileInfo{
210+
Path: path,
211+
Size: hdr.Size,
212+
HumanReadableSize: HumanReadableSize(hdr.Size),
213+
Layer: layer.Digest.Encoded(),
214+
})
127215
if h.Len() > defaultFileLimit {
128216
heap.Pop(h)
129217
}
130218
}
131219
}
132-
133220
}
134221

135-
for i := 0; h.Len() > 0; i++ {
222+
// Extract files from heap in reverse order (largest first)
223+
var files []FileInfo
224+
for h.Len() > 0 {
136225
files = append(files, heap.Pop(h).(FileInfo))
137226
}
138227

139-
maxPathLen, maxSizeLen, maxLayerLen := 0, 0, 12
140-
for _, f := range files {
141-
if len(f.Path) > maxPathLen {
142-
maxPathLen = len(f.Path)
143-
}
144-
sizeStr := strconv.FormatInt(f.Size, 10)
145-
if len(sizeStr) > maxSizeLen {
146-
maxSizeLen = len(sizeStr)
147-
}
148-
}
149-
150-
fmt.Printf("%-*s %*s %-*s\n", maxPathLen, "File Path", maxSizeLen, "Size", maxLayerLen, "Layer ID")
151-
fmt.Println(strings.Repeat("-", maxPathLen+maxSizeLen+maxLayerLen+15)) // also consider two tab chars
228+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.TabIndent)
229+
defer w.Flush()
230+
fmt.Fprintln(w, "FILE PATH\tSIZE\tLAYER ID")
152231

153232
slices.Reverse(files)
154233
for _, f := range files {
155-
sizeStr := strconv.FormatInt(f.Size, 10)
156-
fmt.Printf("%-*s %*s %-*s\n", maxPathLen, f.Path, maxSizeLen, sizeStr, maxLayerLen, f.Layer[:12])
234+
var size string
235+
if humanReadable {
236+
size = f.HumanReadableSize
237+
} else {
238+
size = strconv.FormatInt(f.Size, 10)
239+
}
240+
fmt.Fprintf(w, "%s\t%s\t%s\n", f.Path, size, f.Layer[:12])
157241
}
158-
159242
return nil
160243
}

0 commit comments

Comments
 (0)