Skip to content

Commit 623fa57

Browse files
authored
Rewrite getting ordered layers, add symlinks support (#1)
* rewrite getting layers * show symlinks, add test for symlinks * fix readme
1 parent c1be2ed commit 623fa57

File tree

8 files changed

+164
-87
lines changed

8 files changed

+164
-87
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION := 0.0.1
1+
VERSION := 0.0.2
22

33
build: test
44
go build -ldflags="-X 'main.version=$(VERSION)'" -o docker-tree ./cmd/

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# docker-tree
22

3+
[![License: MIT](https://img.shields.io/badge/License-MIT%202.0-blue.svg)](https://github.com/sergkondr/docker-tree/blob/main/LICENSE)
34
[![GitHub release](https://img.shields.io/github/release/sergkondr/docker-tree.svg)](https://github.com/sergkondr/docker-tree/releases/latest)
45
[![Go Report Card](https://goreportcard.com/badge/github.com/sergkondr/docker-tree)](https://goreportcard.com/report/github.com/sergkondr/docker-tree)
5-
[![License: MIT](https://img.shields.io/badge/License-MIT%202.0-blue.svg)](https://github.com/sergkondr/docker-tree/blob/main/LICENSE)
6+
67

78
This command shows the directory tree of a Docker image, like the 'tree' command.
89
Provide the image name and an optional tag or digest to view the file structure inside the image.
@@ -13,11 +14,12 @@ Think of this app mainly as an attempt to understand how Docker images work and
1314

1415
### Install
1516
```
16-
cp ./docker-tree ~/.docker/cli-plugins/docker-tree
17+
mv ./docker-tree ~/.docker/cli-plugins/docker-tree
1718
```
1819

1920
### Usage
2021
```shell
22+
# Absent image will be pulled automatically
2123
➜ docker tree alpine:3.20 /etc/ssl
2224
3.20: Pulling from library/alpine
2325
a258b2a6b59a: Pull complete
@@ -33,4 +35,17 @@ ssl/
3335
├── openssl.cnf
3436
├── openssl.cnf.dist
3537
└── private/
38+
39+
# Show file tree with symlinks
40+
➜ docker tree -l alpine:3.20 | head
41+
precessing image: alpine:3.20
42+
/
43+
├── bin/
44+
│ ├── arch -> /bin/busybox
45+
│ ├── ash -> /bin/busybox
46+
│ ├── base64 -> /bin/busybox
47+
│ ├── bbconfig -> /bin/busybox
48+
│ ├── busybox
49+
│ ├── cat -> /bin/busybox
50+
│ ├── chattr -> /bin/busybox
3651
```

cmd/main.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ func main() {
2020
plugin.Run(
2121
func(dockerCli command.Cli) *cobra.Command {
2222
var (
23-
quiet bool
23+
quiet bool
24+
showLinks bool
2425
)
2526

2627
cmd := &cobra.Command{
@@ -47,10 +48,11 @@ You can also specify a directory to see the file tree relative to this directory
4748
}
4849

4950
treeStrings, err := docker.GetImageTree(docker.GetTreeOpts{
50-
Cli: dockerCli,
51-
ImageID: imageID,
52-
Quiet: quiet,
53-
TreeRoot: treeRoot,
51+
Cli: dockerCli,
52+
ImageID: imageID,
53+
Quiet: quiet,
54+
ShowLinks: showLinks,
55+
TreeRoot: treeRoot,
5456
})
5557
if err != nil {
5658
fmt.Fprintf(dockerCli.Err(), "can't get image tree: %s\n", err)
@@ -63,6 +65,7 @@ You can also specify a directory to see the file tree relative to this directory
6365

6466
flags := cmd.Flags()
6567
flags.BoolVarP(&quiet, "quiet", "q", false, "Suppress verbose output")
68+
flags.BoolVarP(&showLinks, "links", "l", false, "Show symlinks destination")
6669

6770
cmd.AddCommand()
6871
return cmd

internal/docker/layers.go

Lines changed: 65 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,39 @@ package docker
33
import (
44
"archive/tar"
55
"context"
6+
"encoding/json"
7+
"errors"
68
"fmt"
79
"io"
8-
"slices"
910
"strings"
1011

1112
"github.com/docker/cli/cli/command"
1213
"github.com/docker/docker/api/types/image"
1314
)
1415

16+
const (
17+
manifestFileName = "manifest.json"
18+
layerTarSuffix = "layer.tar"
19+
blobsPrefix = "blobs/sha256/"
20+
)
21+
1522
type layer struct {
1623
ID string
1724
FileTree *fileTreeNode
1825
}
1926

20-
func checkImageExists(cli command.Cli, imageID string) (bool, error) {
21-
images, err := cli.Client().ImageList(context.Background(), image.ListOptions{})
27+
type manifestItem struct {
28+
Config string `json:"Config"`
29+
RepoTags []string `json:"RepoTags"`
30+
Layers []string `json:"Layers"`
31+
}
32+
33+
var (
34+
errNotATar = errors.New("not a tar archive")
35+
)
36+
37+
func checkImageExists(ctx context.Context, cli command.Cli, imageID string) (bool, error) {
38+
images, err := cli.Client().ImageList(ctx, image.ListOptions{})
2239
if err != nil {
2340
return false, fmt.Errorf("can't list images: %w", err)
2441
}
@@ -34,29 +51,11 @@ func checkImageExists(cli command.Cli, imageID string) (bool, error) {
3451
return false, nil
3552
}
3653

37-
func getLayersOrderedArrFromImage(cli command.Cli, imageID string) ([]string, error) {
38-
imageInspect, _, err := cli.Client().ImageInspectWithRaw(context.Background(), imageID)
39-
if err != nil {
40-
return nil, fmt.Errorf("can't inspect image: %w", err)
41-
}
42-
43-
layersOrderedArr := make([]string, len(imageInspect.RootFS.Layers))
44-
for i, layer := range imageInspect.RootFS.Layers {
45-
layersOrderedArr[i] = fmt.Sprintf("%s", strings.Split(layer, ":")[1])
46-
}
47-
48-
return layersOrderedArr, nil
49-
}
50-
51-
func readLayers(cli command.Cli, imageID string, layersOrderedArr []string) (map[string]layer, error) {
52-
imageReader, err := cli.Client().ImageSave(context.Background(), []string{imageID})
53-
if err != nil {
54-
return nil, fmt.Errorf("error saving image: %v", err)
55-
}
56-
defer imageReader.Close()
57-
54+
func getLayersOrderedArrFromImage(imageReader io.ReadCloser) ([]layer, error) {
5855
tarReader := tar.NewReader(imageReader)
59-
filesInImage := make(map[string]layer, len(layersOrderedArr))
56+
manifest := make([]manifestItem, 1)
57+
layerInfoMap := make(map[string]layer, 1)
58+
6059
for {
6160
header, err := tarReader.Next()
6261
if err == io.EOF {
@@ -67,42 +66,70 @@ func readLayers(cli command.Cli, imageID string, layersOrderedArr []string) (map
6766
return nil, fmt.Errorf("error reading tar header: %w", err)
6867
}
6968

70-
fileName := header.Name
71-
if header.Typeflag == tar.TypeReg { //|| header.Typeflag == tar.TypeSymlink {
72-
fName := strings.TrimPrefix(fileName, "blobs/sha256/")
73-
if slices.Contains(layersOrderedArr, fName) {
69+
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeSymlink {
70+
if header.Name == manifestFileName {
71+
fileReader, err := io.ReadAll(tarReader)
72+
if err != nil {
73+
return nil, fmt.Errorf("error reading tar content: %w", err)
74+
}
75+
76+
if err = json.Unmarshal(fileReader, &manifest); err != nil {
77+
return nil, fmt.Errorf("error unmarshalling manifest: %w", err)
78+
}
79+
} else if strings.HasSuffix(header.Name, layerTarSuffix) {
7480
layerReader := tar.NewReader(tarReader)
7581
files, err := getFileTreeFromLayer(layerReader)
7682
if err != nil {
7783
return nil, fmt.Errorf("error getting files from layer: %w", err)
7884
}
7985

80-
filesInImage[fName] = layer{
81-
ID: fileName,
86+
layerInfoMap[header.Name] = layer{
87+
ID: header.Name,
88+
FileTree: files,
89+
}
90+
} else if strings.HasPrefix(header.Name, blobsPrefix) {
91+
layerReader := tar.NewReader(tarReader)
92+
files, err := getFileTreeFromLayer(layerReader)
93+
if errors.Is(err, errNotATar) {
94+
continue
95+
}
96+
97+
if err != nil {
98+
return nil, fmt.Errorf("error getting files from layer: %w", err)
99+
}
100+
101+
layerInfoMap[header.Name] = layer{
102+
ID: header.Name,
82103
FileTree: files,
83104
}
84105
}
85106
}
86107
}
87108

88-
return filesInImage, nil
109+
orderedLayersArr := make([]layer, len(manifest[0].Layers))
110+
for i, layer := range manifest[0].Layers {
111+
orderedLayersArr[i] = layerInfoMap[layer]
112+
}
113+
114+
return orderedLayersArr, nil
89115
}
90116

91117
func getFileTreeFromLayer(layerReader *tar.Reader) (*fileTreeNode, error) {
92-
fileTree := &fileTreeNode{"/", true, make([]*fileTreeNode, 0)}
93-
118+
fileTree := &fileTreeNode{
119+
Name: "/",
120+
Symlink: "",
121+
IsDir: true,
122+
Children: make([]*fileTreeNode, 0),
123+
}
94124
for {
95125
header, err := layerReader.Next()
96126
if err == io.EOF {
97127
break
98128
}
99-
100129
if err != nil {
101-
return nil, fmt.Errorf("error reading tar header: %w", err)
130+
return nil, errNotATar
102131
}
103-
104132
fileTree.addChild(header)
105133
}
106-
107134
return fileTree, nil
108135
}

internal/docker/main.go

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
package docker
22

33
import (
4+
"context"
45
"fmt"
56

67
"github.com/docker/cli/cli/command"
78
)
89

910
type GetTreeOpts struct {
10-
Cli command.Cli
11-
ImageID string
12-
Quiet bool
13-
TreeRoot string
11+
Cli command.Cli
12+
13+
ImageID string
14+
Quiet bool
15+
ShowLinks bool
16+
TreeRoot string
1417
}
1518

1619
func GetImageTree(opts GetTreeOpts) (string, error) {
17-
imageExists, err := checkImageExists(opts.Cli, opts.ImageID)
20+
ctx := context.Background()
21+
22+
imageExists, err := checkImageExists(ctx, opts.Cli, opts.ImageID)
1823
if err != nil {
1924
return "", fmt.Errorf("can't check if image exists: %w", err)
2025
}
@@ -30,26 +35,34 @@ func GetImageTree(opts GetTreeOpts) (string, error) {
3035
}
3136
}
3237

33-
layersOrderedArr, err := getLayersOrderedArrFromImage(opts.Cli, opts.ImageID)
38+
if !opts.Quiet {
39+
fmt.Fprintf(opts.Cli.Out(), "precessing image: %s\n", opts.ImageID)
40+
}
41+
42+
imageReader, err := opts.Cli.Client().ImageSave(ctx, []string{opts.ImageID})
3443
if err != nil {
35-
return "", fmt.Errorf("can't get layersOrderedArr: %w", err)
44+
return "", fmt.Errorf("error saving image: %v", err)
3645
}
46+
defer imageReader.Close()
3747

38-
layerMap, err := readLayers(opts.Cli, opts.ImageID, layersOrderedArr)
48+
layersOrderedArr, err := getLayersOrderedArrFromImage(imageReader)
3949
if err != nil {
40-
return "", fmt.Errorf("can't read layer: %w", err)
50+
return "", fmt.Errorf("can't get layersOrderedArr: %w", err)
4151
}
4252

43-
originalLayer := layerMap[layersOrderedArr[0]].FileTree
53+
originalLayer := layersOrderedArr[0].FileTree
4454
for i := 1; i <= len(layersOrderedArr)-1; i++ {
45-
updatedLayer := layerMap[layersOrderedArr[i]].FileTree
46-
originalLayer, _ = mergeFileTrees(originalLayer, updatedLayer)
55+
updatedLayer := layersOrderedArr[i].FileTree
56+
originalLayer, err = mergeFileTrees(originalLayer, updatedLayer)
57+
if err != nil {
58+
return "", fmt.Errorf("can't merge layers: %w", err)
59+
}
4760
}
4861

4962
node := originalLayer.findNode(opts.TreeRoot)
5063
if node == nil {
51-
return "", fmt.Errorf("tree is no such path in the image: %s", opts.TreeRoot)
64+
return "", fmt.Errorf("there is no such path in the image: %s", opts.TreeRoot)
5265
}
5366

54-
return node.String(), nil
67+
return node.getString("", opts.ShowLinks, true, true), nil
5568
}

internal/docker/run_cli.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import (
55
"os/exec"
66
)
77

8-
// unfortunately, we need this to pull an image, because we can't call pull method directly
9-
// due to unexported parameters of the method, so it is impossible to specify the image outside the docker package
8+
// Unfortunately, we need this to pull an image, because we can't call pull method directly
9+
// due to unexported parameters of the method, so it is impossible to specify the image outside the docker package.
10+
// And I'd like to use this command because of its clear and informative output.
1011
func runDockerCliCommand(args, env []string) error {
1112
cmd := exec.Command("docker", args...)
1213
cmd.Env = append(os.Environ(), env...)

internal/docker/tree.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,19 @@ const (
1313
branch = "│ "
1414
middle = "├── "
1515
last = "└── "
16+
link = " -> "
1617

1718
delFilePrefix = ".wh."
1819
)
1920

2021
type fileTreeNode struct {
2122
Name string
23+
Symlink string
2224
IsDir bool
2325
Children []*fileTreeNode
2426
}
2527

26-
func (n *fileTreeNode) String() string {
27-
return n.getString("", true, true)
28-
}
29-
30-
func (n *fileTreeNode) getString(prefix string, isFirst, isLast bool) string {
28+
func (n *fileTreeNode) getString(prefix string, showLinks, isFirst, isLast bool) string {
3129
passPrefix := prefix
3230
currentPrefix := empty
3331

@@ -47,9 +45,12 @@ func (n *fileTreeNode) getString(prefix string, isFirst, isLast bool) string {
4745
}
4846

4947
result := fmt.Sprintf("%s%s%s\n", prefix, currentPrefix, name)
48+
if showLinks && n.Symlink != "" {
49+
result = fmt.Sprintf("%s%s%s%s%s\n", prefix, currentPrefix, name, link, n.Symlink)
50+
}
5051

5152
for i, child := range n.Children {
52-
result += child.getString(passPrefix, false, i == len(n.Children)-1)
53+
result += child.getString(passPrefix, showLinks, false, i == len(n.Children)-1)
5354
}
5455

5556
return result
@@ -73,6 +74,11 @@ func (n *fileTreeNode) addChild(file *tar.Header) {
7374
Name: dir,
7475
IsDir: file.Typeflag == tar.TypeDir,
7576
}
77+
78+
if file.Typeflag == tar.TypeSymlink {
79+
child.Symlink = file.Linkname
80+
}
81+
7682
n.Children = append(n.Children, child)
7783
}
7884
}
@@ -110,10 +116,15 @@ func mergeFileTrees(original, updated *fileTreeNode) (*fileTreeNode, error) {
110116
return updated, nil
111117
}
112118

113-
merged := &fileTreeNode{original.Name, original.IsDir, original.Children}
119+
merged := &fileTreeNode{
120+
Name: original.Name,
121+
Symlink: "",
122+
IsDir: original.IsDir,
123+
Children: original.Children,
124+
}
114125

115126
for _, updatedChild := range updated.Children {
116-
// to avoid "/./" in tree for distroless images
127+
// to avoid "/./" in tree for some images
117128
if updatedChild.Name == "." {
118129
continue
119130
}

0 commit comments

Comments
 (0)