Skip to content

Commit 761f572

Browse files
authored
Merge pull request #1 from stackb/feat/extract
Add extract command to retrieve files from image layers
2 parents 5540809 + 643468a commit 761f572

3 files changed

Lines changed: 457 additions & 3 deletions

File tree

.github/workflows/pr.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: PR
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- master
7+
8+
jobs:
9+
check:
10+
name: Vet, Build, Test
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Check out code
15+
uses: actions/checkout@v2
16+
17+
- name: Set up Go 1.16
18+
uses: actions/setup-go@v2
19+
with:
20+
go-version: 1.16
21+
id: go
22+
23+
- name: Vet
24+
run: go vet ./...
25+
26+
- name: Build
27+
run: go build ./...
28+
29+
- name: Test
30+
run: go test ./...

main.go

Lines changed: 155 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"fmt"
66
"io"
77
"os"
8+
"path/filepath"
89
"sort"
910
"strconv"
11+
"strings"
1012
"text/tabwriter"
1113

1214
"github.com/dustin/go-humanize"
@@ -31,6 +33,7 @@ func main() {
3133
Action: func(c *cli.Context) error {
3234
cfg := &config{
3335
ref: c.Args().First(),
36+
out: os.Stdout,
3437
}
3538
if err := inspect(cfg); err != nil {
3639
return cli.Exit(c.Command.Name+": "+err.Error(), 1)
@@ -46,6 +49,7 @@ func main() {
4649
ref: c.Args().First(),
4750
layerIDs: c.Args().Tail(),
4851
sort: c.Bool("sort"),
52+
out: os.Stdout,
4953
}
5054

5155
if err := ls(cfg); err != nil {
@@ -61,6 +65,28 @@ func main() {
6165
},
6266
},
6367
},
68+
{
69+
Name: "extract",
70+
Usage: "extract files from an image and print to stdout",
71+
Action: func(c *cli.Context) error {
72+
cfg := &config{
73+
ref: c.Args().First(),
74+
files: c.Args().Tail(),
75+
outputDir: c.String("output_dir"),
76+
out: os.Stdout,
77+
}
78+
if err := extract(cfg); err != nil {
79+
return cli.Exit(c.Command.Name+": "+err.Error(), 1)
80+
}
81+
return nil
82+
},
83+
Flags: []cli.Flag{
84+
&cli.StringFlag{
85+
Name: "output_dir",
86+
Usage: "Write extracted files to this directory instead of stdout",
87+
},
88+
},
89+
},
6490
},
6591
}
6692

@@ -75,6 +101,12 @@ type config struct {
75101
layerIDs []string
76102
// sort is true if the output should be sorted by size.
77103
sort bool
104+
// files is the list of file paths to extract.
105+
files []string
106+
// outputDir is the directory to write extracted files to.
107+
outputDir string
108+
// out is the writer for output.
109+
out io.Writer
78110
}
79111

80112
// makeOptions returns the options for crane.
@@ -148,13 +180,17 @@ func inspect(cfg *config) error {
148180
if err != nil {
149181
return err
150182
}
183+
return inspectImage(cfg, image)
184+
}
151185

186+
// inspectImage prints info about the layers of an image.
187+
func inspectImage(cfg *config, image v1.Image) error {
152188
layers, err := image.Layers()
153189
if err != nil {
154190
return err
155191
}
156192

157-
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
193+
tw := tabwriter.NewWriter(cfg.out, 0, 0, 2, ' ', 0)
158194
tw.Write([]byte("N\tLayer\tSize\n"))
159195
for i, layer := range layers {
160196
hash, err := layer.DiffID()
@@ -178,7 +214,11 @@ func ls(cfg *config) error {
178214
if err != nil {
179215
return err
180216
}
217+
return lsImage(cfg, image)
218+
}
181219

220+
// lsImage lists files in the layers of an image.
221+
func lsImage(cfg *config, image v1.Image) error {
182222
layers, err := image.Layers()
183223
if err != nil {
184224
return fmt.Errorf("getting layers: %w", err)
@@ -237,8 +277,8 @@ func files(cfg *config, layer v1.Layer) error {
237277

238278
tarReader := tar.NewReader(uncompressed)
239279

240-
fmt.Printf("\n--- %s ---\n", hash)
241-
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
280+
fmt.Fprintf(cfg.out, "\n--- %s ---\n", hash)
281+
tw := tabwriter.NewWriter(cfg.out, 0, 0, 2, ' ', 0)
242282
tw.Write([]byte("Mode\tSize\tName\n"))
243283

244284
headers := make([]*tar.Header, 0)
@@ -271,3 +311,115 @@ func files(cfg *config, layer v1.Layer) error {
271311

272312
return tw.Flush()
273313
}
314+
315+
// extract extracts files from an image and writes them to stdout or a directory.
316+
func extract(cfg *config) error {
317+
if len(cfg.files) == 0 {
318+
return fmt.Errorf("no files specified")
319+
}
320+
321+
image, err := getImage(cfg.ref)
322+
if err != nil {
323+
return err
324+
}
325+
326+
return extractFromImage(cfg, image)
327+
}
328+
329+
// extractFromImage extracts files from the given image.
330+
func extractFromImage(cfg *config, image v1.Image) error {
331+
layers, err := image.Layers()
332+
if err != nil {
333+
return fmt.Errorf("getting layers: %w", err)
334+
}
335+
336+
// Build a set of wanted files for quick lookup.
337+
// Normalize by stripping leading slash.
338+
wanted := make(map[string]bool, len(cfg.files))
339+
for _, f := range cfg.files {
340+
wanted[strings.TrimPrefix(f, "/")] = true
341+
}
342+
343+
found := make(map[string]bool, len(cfg.files))
344+
345+
// Search layers in reverse order (last wins) to match container runtime behavior.
346+
for i := len(layers) - 1; i >= 0; i-- {
347+
layer := layers[i]
348+
349+
uncompressed, err := layer.Uncompressed()
350+
if err != nil {
351+
return fmt.Errorf("getting layer: %w", err)
352+
}
353+
354+
tarReader := tar.NewReader(uncompressed)
355+
for {
356+
header, err := tarReader.Next()
357+
if err == io.EOF {
358+
break
359+
}
360+
if err != nil {
361+
uncompressed.Close()
362+
return fmt.Errorf("reading tar: %w", err)
363+
}
364+
365+
name := strings.TrimPrefix(header.Name, "./")
366+
name = strings.TrimPrefix(name, "/")
367+
368+
if !wanted[name] || found[name] {
369+
continue
370+
}
371+
372+
if err := extractFile(cfg, name, tarReader); err != nil {
373+
uncompressed.Close()
374+
return err
375+
}
376+
found[name] = true
377+
378+
// Stop early if all files found.
379+
if len(found) == len(wanted) {
380+
uncompressed.Close()
381+
return nil
382+
}
383+
}
384+
uncompressed.Close()
385+
}
386+
387+
// Report any files not found.
388+
var missing []string
389+
for _, f := range cfg.files {
390+
name := strings.TrimPrefix(f, "/")
391+
if !found[name] {
392+
missing = append(missing, f)
393+
}
394+
}
395+
if len(missing) > 0 {
396+
return fmt.Errorf("files not found: %s", strings.Join(missing, ", "))
397+
}
398+
399+
return nil
400+
}
401+
402+
// extractFile writes the contents of a tar entry to stdout or to a file under outputDir.
403+
func extractFile(cfg *config, name string, r io.Reader) error {
404+
if cfg.outputDir == "" {
405+
_, err := io.Copy(cfg.out, r)
406+
return err
407+
}
408+
409+
outPath := filepath.Join(cfg.outputDir, name)
410+
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
411+
return fmt.Errorf("creating directory for %s: %w", name, err)
412+
}
413+
414+
f, err := os.Create(outPath)
415+
if err != nil {
416+
return fmt.Errorf("creating file %s: %w", outPath, err)
417+
}
418+
defer f.Close()
419+
420+
if _, err := io.Copy(f, r); err != nil {
421+
return fmt.Errorf("writing file %s: %w", outPath, err)
422+
}
423+
424+
return nil
425+
}

0 commit comments

Comments
 (0)