Skip to content

Commit e007d8a

Browse files
authored
support more sources through specific schemes (#7)
Co-authored-by: CrazyMax <[email protected]>
1 parent 9066dff commit e007d8a

File tree

14 files changed

+481
-126
lines changed

14 files changed

+481
-126
lines changed

.github/workflows/e2e.yml

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010
pull_request:
1111

1212
jobs:
13-
e2e:
13+
registry:
1414
runs-on: ubuntu-latest
1515
strategy:
1616
fail-fast: false
@@ -34,8 +34,6 @@ jobs:
3434
-
3535
name: Checkout
3636
uses: actions/checkout@v2
37-
with:
38-
fetch-depth: 0
3937
-
4038
name: Set up Docker Buildx
4139
uses: docker/setup-buildx-action@v1
@@ -66,3 +64,69 @@ jobs:
6664
name: Dist content
6765
run: |
6866
tree -nh ./dist
67+
68+
docker-daemon:
69+
runs-on: ubuntu-latest
70+
steps:
71+
-
72+
name: Checkout
73+
uses: actions/checkout@v2
74+
-
75+
name: Set up Docker Buildx
76+
uses: docker/setup-buildx-action@v1
77+
-
78+
name: Build
79+
uses: docker/bake-action@v1
80+
with:
81+
targets: binary
82+
-
83+
name: Create Dockerfile
84+
run: |
85+
mkdir ./bin/test
86+
cat > ./bin/test/Dockerfile <<EOL
87+
FROM alpine
88+
RUN mkdir hello && echo "Hello, world!" > /hello/world
89+
EOL
90+
-
91+
name: Build image and load
92+
uses: docker/build-push-action@v2
93+
with:
94+
context: ./bin/test
95+
load: true
96+
tags: image:local
97+
-
98+
name: Run
99+
run: |
100+
./bin/undock --rm-dist --include /hello docker-daemon://image:local ./dist
101+
-
102+
name: Dist content
103+
run: |
104+
tree -nh ./dist
105+
106+
docker-archive:
107+
runs-on: ubuntu-latest
108+
steps:
109+
-
110+
name: Checkout
111+
uses: actions/checkout@v2
112+
-
113+
name: Set up Docker Buildx
114+
uses: docker/setup-buildx-action@v1
115+
-
116+
name: Build
117+
uses: docker/bake-action@v1
118+
with:
119+
targets: binary
120+
-
121+
name: Create docker archive
122+
run: |
123+
docker pull crazymax/buildx-pkg:latest
124+
docker save crazymax/buildx-pkg:latest > archive.tar
125+
-
126+
name: Run
127+
run: |
128+
./bin/undock --rm-dist docker-archive://archive.tar ./dist
129+
-
130+
name: Dist content
131+
run: |
132+
tree -nh ./dist

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ local machine with a single command.
2222

2323
## Features
2424

25+
* Many source support (docker, archive, store, oci, tar)
2526
* Can extract multi-platform images
2627
* Include a subset of files/dirs
2728
* Cache support

docs/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
---
1616

17-
## What is Undock?
17+
## What's Undock?
1818

1919
**Undock** is a CLI application that allows you to extract contents of a
2020
container image in a local folder. This can be useful if you use a registry
@@ -28,6 +28,7 @@ See the [usage examples](usage/examples.md) for more info.
2828

2929
## Features
3030

31+
* Many source support (docker, archive, store, oci, tar)
3132
* Can extract multi-platform images
3233
* Include a subset of files/dirs
3334
* Cache support

docs/usage/cli.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Usage
44

55
```shell
6-
undock [options]
6+
undock [options] <source> <dist>
77
```
88

99
## Options
@@ -15,7 +15,7 @@ Usage: undock <source> <dist>
1515
Extract contents of a container image in a local folder. More info: https://github.com/crazy-max/undock
1616
1717
Arguments:
18-
<source> Source image from a registry. (eg. alpine:latest)
18+
<source> Source image. (eg. alpine:latest)
1919
<dist> Dist folder. (eg. ./dist)
2020
2121
Flags:
@@ -29,11 +29,43 @@ Flags:
2929
--platform=STRING Enforce platform for source image. (eg. linux/amd64)
3030
--all Extract all architectures if source is a manifest list.
3131
--include=INCLUDE,... Include a subset of files/dirs from the source image.
32-
--insecure Allow contacting the registry over HTTP, or HTTPS with failed TLS verification.
32+
--insecure Allow contacting the registry or docker daemon over HTTP, or HTTPS with failed TLS verification.
3333
--rm-dist Removes dist folder.
3434
--wrap For a manifest list, merge output in dist folder.
3535
```
3636

37+
### Source image
38+
39+
`source` argument can be a container image from a registry, a local docker
40+
image, a container store reference, etc. Following schemes can be used:
41+
42+
* `containers-storage://<store>`: image located in a local container storage[^1].
43+
* `docker://<ref>`: image in a registry implementing the "Docker Registry HTTP API V2"[^1].
44+
* `docker-archive://<path>`: image is stored in the `docker-save` formatted file[^1].
45+
* `docker-daemon://<ref>`: image stored in the docker daemon's internal storage[^1].
46+
* `oci://<path>`: image compliant with the "Open Container Image Layout Specification"[^1].
47+
* `oci-archive://<path>`: image compliant with the "Open Container Image Layout Specification" stored as a tar archive[^1].
48+
* `ostree://<ref>`: image in the local ostree repository[^1].
49+
50+
!!! note
51+
`docker://` is used by default if scheme unset.
52+
53+
```console
54+
# registry image
55+
undock --rm-dist crazymax/buildx-pkg:latest ./dist
56+
# or
57+
undock --rm-dist docker://crazymax/buildx-pkg:latest ./dist
58+
59+
# archive docker image
60+
docker pull crazymax/buildx-pkg:latest
61+
docker save crazymax/buildx-pkg:latest > archive.tar
62+
undock --rm-dist docker-archive://archive.tar ./dist
63+
64+
# local docker image
65+
docker build -t myimage:local .
66+
undock --rm-dist docker-daemon://myimage:local ./dist
67+
```
68+
3769
## Environment variables
3870

3971
Following environment variables can be used in place:
@@ -44,3 +76,5 @@ Following environment variables can be used in place:
4476
| `LOG_JSON` | `false` | Enable JSON logging output |
4577
| `LOG_CALLER` | `false` | Enable to add `file:line` of the caller |
4678
| `LOG_NOCOLOR` | `false` | Disable the colorized output |
79+
80+
[^1]: See [containers image transport page](https://github.com/containers/image/blob/main/docs/containers-transports.5.md) for more info.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/alecthomas/kong v0.4.0
77
github.com/containerd/containerd v1.5.9
88
github.com/containers/image/v5 v5.19.1
9+
github.com/docker/docker v20.10.12+incompatible
910
github.com/mholt/archiver/v4 v4.0.0-alpha.4
1011
github.com/opencontainers/go-digest v1.0.0
1112
github.com/opencontainers/image-spec v1.0.3-0.20211202193544-a5463b7f9c84
@@ -34,7 +35,6 @@ require (
3435
github.com/cyphar/filepath-securejoin v0.2.3 // indirect
3536
github.com/davecgh/go-spew v1.1.1 // indirect
3637
github.com/docker/distribution v2.7.1+incompatible // indirect
37-
github.com/docker/docker v20.10.12+incompatible // indirect
3838
github.com/docker/docker-credential-helpers v0.6.4 // indirect
3939
github.com/docker/go-connections v0.4.0 // indirect
4040
github.com/docker/go-metrics v0.0.1 // indirect

internal/app/undock.go

Lines changed: 24 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,17 @@ package app
22

33
import (
44
"context"
5-
"fmt"
65
"os"
7-
"path"
86
"path/filepath"
7+
"strings"
98

109
"github.com/containerd/containerd/platforms"
11-
"github.com/containers/image/v5/manifest"
1210
"github.com/crazy-max/undock/internal/config"
11+
"github.com/crazy-max/undock/internal/extractor"
12+
ximage "github.com/crazy-max/undock/internal/extractor/image"
13+
"github.com/crazy-max/undock/pkg/image"
1314
specs "github.com/opencontainers/image-spec/specs-go/v1"
1415
"github.com/pkg/errors"
15-
"github.com/rs/zerolog/log"
16-
"golang.org/x/sync/errgroup"
1716
)
1817

1918
// Undock represents an active undock object
@@ -70,76 +69,33 @@ func (c *Undock) Start() error {
7069
return errors.Wrapf(err, "failed to create dist folder %q", c.cli.Dist)
7170
}
7271

73-
logger := log.With().Str("src", c.cli.Source).Logger()
72+
var (
73+
xcli *extractor.Client
74+
err error
75+
)
7476

75-
logger.Info().Msg("Extracting source image")
76-
manblob, cachedir, err := c.cacheSource(logger, c.cli.Source)
77-
if err != nil {
78-
return errors.Wrap(err, "cannot cache source")
77+
switch {
78+
case isImageScheme(c.cli.Source):
79+
xcli, err = ximage.New(c.ctx, c.meta, c.cli, c.platform)
80+
default:
81+
return errors.Errorf("unsupported source %q", c.cli.Source)
7982
}
80-
81-
type manifestEntry struct {
82-
platform specs.Platform
83-
manifest *manifest.OCI1
83+
if err != nil {
84+
return err
8485
}
8586

86-
var mans []manifestEntry
87+
return xcli.Extract()
88+
}
8789

88-
mtype := manifest.GuessMIMEType(manblob)
89-
if mtype == specs.MediaTypeImageManifest {
90-
man, err := manifest.OCI1FromManifest(manblob)
91-
if err != nil {
92-
return errors.Wrap(err, "cannot create OCI manifest instance from blob")
93-
}
94-
mans = append(mans, manifestEntry{
95-
platform: c.platform,
96-
manifest: man,
97-
})
98-
} else if mtype == specs.MediaTypeImageIndex {
99-
ocindex, err := manifest.OCI1IndexFromManifest(manblob)
100-
if err != nil {
101-
return errors.Wrap(err, "cannot create OCI manifest index instance from blob")
102-
}
103-
for _, m := range ocindex.Manifests {
104-
mblob, err := os.ReadFile(path.Join(cachedir, "blobs", m.Digest.Algorithm().String(), m.Digest.Hex()))
105-
if err != nil {
106-
return errors.Wrapf(err, "cannot read OCI manifest JSON for platform %s", platforms.Format(*m.Platform))
107-
}
108-
man, err := manifest.OCI1FromManifest(mblob)
109-
if err != nil {
110-
return errors.Wrap(err, "cannot create OCI manifest instance from blob")
111-
}
112-
mans = append(mans, manifestEntry{
113-
platform: *m.Platform,
114-
manifest: man,
115-
})
90+
func isImageScheme(source string) bool {
91+
schemes := []string{"containers-storage", "docker", "docker-archive", "docker-daemon", "oci", "oci-archive", "ostree"}
92+
for _, scheme := range schemes {
93+
if strings.HasPrefix(source, scheme+"://") {
94+
return true
11695
}
11796
}
118-
119-
eg, _ := errgroup.WithContext(c.ctx)
120-
for _, mane := range mans {
121-
func(mane manifestEntry) {
122-
eg.Go(func() error {
123-
dest := c.cli.Dist
124-
if !c.cli.Wrap && len(mans) > 1 {
125-
dest = path.Join(c.cli.Dist, fmt.Sprintf("%s_%s%s", mane.platform.OS, mane.platform.Architecture, mane.platform.Variant))
126-
}
127-
if err := os.MkdirAll(dest, 0700); err != nil {
128-
return errors.Wrapf(err, "failed to create destination folder %q", dest)
129-
}
130-
for _, layer := range mane.manifest.LayerInfos() {
131-
sublogger := logger.With().Str("platform", platforms.Format(mane.platform)).Str("blob", layer.Digest.String()).Logger()
132-
sublogger.Info().Msgf("Extracting blob")
133-
if err = c.extract(sublogger, path.Join(cachedir, "blobs", layer.Digest.Algorithm().String(), layer.Digest.Hex()), dest); err != nil {
134-
return err
135-
}
136-
}
137-
return nil
138-
})
139-
}(mane)
140-
}
141-
142-
return eg.Wait()
97+
_, err := image.Reference(source)
98+
return err == nil
14399
}
144100

145101
// Close closes undock

internal/config/cli.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ type Cli struct {
1515

1616
All bool `kong:"name=all,default=false,help='Extract all architectures if source is a manifest list.'"`
1717
Includes []string `kong:"name=include,help='Include a subset of files/dirs from the source image.'"`
18-
Insecure bool `kong:"name=insecure,default=false,help='Allow contacting the registry over HTTP, or HTTPS with failed TLS verification.'"`
18+
Insecure bool `kong:"name=insecure,default=false,help='Allow contacting the registry or docker daemon over HTTP, or HTTPS with failed TLS verification.'"`
1919
RmDist bool `kong:"name=rm-dist,default=false,help='Removes dist folder.'"`
2020
Wrap bool `kong:"name=wrap,default=false,help='For a manifest list, merge output in dist folder.'"`
2121

22-
Source string `kong:"arg,required,name=source,help='Source image from a registry. (eg. alpine:latest)'"`
22+
Source string `kong:"arg,required,name=source,help='Source image. (eg. alpine:latest)'"`
2323
Dist string `kong:"arg,required,name=dist,type=path,help='Dist folder. (eg. ./dist)'"`
2424
}

internal/app/extract.go renamed to internal/extractor/blob.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package app
1+
package extractor
22

33
import (
44
"context"
@@ -14,7 +14,16 @@ import (
1414
"github.com/rs/zerolog"
1515
)
1616

17-
func (c *Undock) extract(logger zerolog.Logger, filename string, dest string) error {
17+
// ExtractBlobOpts holds extract blob options
18+
type ExtractBlobOpts struct {
19+
Context context.Context
20+
Logger zerolog.Logger
21+
Includes []string
22+
}
23+
24+
func ExtractBlob(filename string, dest string, opts ExtractBlobOpts) error {
25+
opts.Logger.Info().Msgf("Extracting blob")
26+
1827
var r io.ReadCloser
1928
dt, err := os.Open(filename)
2029
if err != nil {
@@ -26,7 +35,7 @@ func (c *Undock) extract(logger zerolog.Logger, filename string, dest string) er
2635
if err != nil {
2736
return err
2837
}
29-
logger.Debug().Msgf("Blob format %s detected", format.Name())
38+
opts.Logger.Debug().Msgf("Blob format %s detected", format.Name())
3039

3140
var u archiver.Extractor
3241
var d archiver.Decompressor
@@ -56,23 +65,21 @@ func (c *Undock) extract(logger zerolog.Logger, filename string, dest string) er
5665
}
5766

5867
var pathsInArchive []string
59-
if len(c.cli.Includes) > 0 {
60-
for _, inc := range c.cli.Includes {
61-
inc = strings.TrimPrefix(inc, "/")
62-
if len(inc) > 0 {
63-
pathsInArchive = append(pathsInArchive, inc)
64-
}
68+
for _, inc := range opts.Includes {
69+
inc = strings.TrimPrefix(inc, "/")
70+
if len(inc) > 0 {
71+
pathsInArchive = append(pathsInArchive, inc)
6572
}
6673
}
6774
if len(pathsInArchive) == 0 {
6875
pathsInArchive = nil
6976
}
7077

71-
err = u.Extract(c.ctx, r, pathsInArchive, func(ctx context.Context, f archiver.File) error {
78+
err = u.Extract(opts.Context, r, pathsInArchive, func(ctx context.Context, f archiver.File) error {
7279
if f.FileInfo.IsDir() {
73-
logger.Trace().Msgf("Extracting %s", f.NameInArchive)
80+
opts.Logger.Trace().Msgf("Extracting %s", f.NameInArchive)
7481
} else {
75-
logger.Debug().Msgf("Extracting %s", f.NameInArchive)
82+
opts.Logger.Debug().Msgf("Extracting %s", f.NameInArchive)
7683
}
7784

7885
path := filepath.Join(dest, f.NameInArchive)

0 commit comments

Comments
 (0)