Skip to content

Commit ed5bb02

Browse files
committed
add podman artifact extract
Add a new command to extract the blob content of the artifact store to a local path. Fixes https://issues.redhat.com/browse/RUN-2445 Signed-off-by: Paul Holzinger <[email protected]>
1 parent 2cbb5fe commit ed5bb02

File tree

12 files changed

+524
-1
lines changed

12 files changed

+524
-1
lines changed

cmd/podman/artifact/extract.go

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package artifact
2+
3+
import (
4+
"github.com/containers/common/pkg/completion"
5+
"github.com/containers/podman/v5/cmd/podman/common"
6+
"github.com/containers/podman/v5/cmd/podman/registry"
7+
"github.com/containers/podman/v5/pkg/domain/entities"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var (
12+
extractCmd = &cobra.Command{
13+
Use: "extract [options] ARTIFACT PATH",
14+
Short: "Extract an OCI artifact to a local path",
15+
Long: "Extract the blobs of an OCI artifact to a local file or directory",
16+
RunE: extract,
17+
Args: cobra.ExactArgs(2),
18+
ValidArgsFunction: common.AutocompleteArtifactAdd,
19+
Example: `podman artifact Extract quay.io/myimage/myartifact:latest /tmp/foobar.txt
20+
podman artifact Extract quay.io/myimage/myartifact:latest /home/paul/mydir`,
21+
Annotations: map[string]string{registry.EngineMode: registry.ABIMode},
22+
}
23+
)
24+
25+
var (
26+
extractOpts entities.ArtifactExtractOptions
27+
)
28+
29+
func init() {
30+
registry.Commands = append(registry.Commands, registry.CliCommand{
31+
Command: extractCmd,
32+
Parent: artifactCmd,
33+
})
34+
flags := extractCmd.Flags()
35+
36+
digestFlagName := "digest"
37+
flags.StringVar(&extractOpts.Digest, digestFlagName, "", "Only extract blob with the given digest")
38+
_ = extractCmd.RegisterFlagCompletionFunc(digestFlagName, completion.AutocompleteNone)
39+
40+
titleFlagName := "title"
41+
flags.StringVar(&extractOpts.Title, titleFlagName, "", "Only extract blob with the given title")
42+
_ = extractCmd.RegisterFlagCompletionFunc(titleFlagName, completion.AutocompleteNone)
43+
}
44+
45+
func extract(cmd *cobra.Command, args []string) error {
46+
err := registry.ImageEngine().ArtifactExtract(registry.Context(), args[0], args[1], &extractOpts)
47+
if err != nil {
48+
return err
49+
}
50+
51+
return nil
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
% podman-artifact-extract 1
2+
3+
4+
## WARNING: Experimental command
5+
*This command is considered experimental and still in development. Inputs, options, and outputs are all
6+
subject to change.*
7+
8+
## NAME
9+
podman\-artifact\-extract - Extract an OCI artifact to a local path
10+
11+
## SYNOPSIS
12+
**podman artifact extract** *artifact* *target*
13+
14+
## DESCRIPTION
15+
16+
Extract the blobs of an OCI artifact to a local file or directory.
17+
18+
If the target path is a file or does not exist, the artifact must either consist
19+
of one blob (layer) or if it has multiple blobs (layers) then the **--digest** or
20+
**--title** option must be used to select only a single blob. If the file already
21+
exists it will be overwritten.
22+
23+
If the target is a directory (it must exist), all blobs will be copied to the
24+
target directory. As the target file name the value from the `org.opencontainers.image.title`
25+
annotation is used. If the annotation is missing, the target file name will be the
26+
digest of the blob (with `:` replaced by `-` in the name).
27+
If the target file already exists in the directory, it will be overwritten.
28+
29+
## OPTIONS
30+
31+
#### **--digest**=**digest**
32+
33+
When extracting blobs from the artifact only use the one with the specified digest.
34+
If the target is a directory then the digest is always used as file name instead even
35+
when the title annotation exists on the blob.
36+
Conflicts with **--title**.
37+
38+
#### **--help**
39+
40+
Print usage statement.
41+
42+
#### **--title**=**title**
43+
44+
When extracting blobs from the artifact only use the one with the specified title.
45+
It looks for the `org.opencontainers.image.title` annotation and compares that
46+
against the given title.
47+
Conflicts with **--digest**.
48+
49+
## EXAMPLES
50+
51+
Extract an artifact with a single blob
52+
53+
```
54+
$ podman artifact extract quay.io/artifact/foobar1:test /tmp/myfile
55+
```
56+
57+
Extract an artifact with multiple blobs
58+
59+
```
60+
$ podman artifact extract quay.io/artifact/foobar2:test /tmp/mydir
61+
$ ls /tmp/mydir
62+
CONTRIBUTING.md README.md
63+
```
64+
65+
Extract only a single blob from an artifact with multiple blobs
66+
67+
```
68+
$ podman artifact extract --title README.md quay.io/artifact/foobar2:test /tmp/mydir
69+
$ ls /tmp/mydir
70+
README.md
71+
```
72+
Or using the digest instead of the title
73+
```
74+
$ podman artifact extract --digest sha256:c0594e012b17fd9e6548355ceb571a79613f7bb988d7d883f112513601ac6e9a quay.io/artifact/foobar2:test /tmp/mydir
75+
$ ls /tmp/mydir
76+
README.md
77+
```
78+
79+
## SEE ALSO
80+
**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)**
81+
82+
## HISTORY
83+
Feb 2025, Originally compiled by Paul Holzinger <[email protected]>

docs/source/markdown/podman-artifact.1.md

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ from its local "artifact store".
2222
| Command | Man Page | Description |
2323
|---------|------------------------------------------------------------|--------------------------------------------------------------|
2424
| add | [podman-artifact-add(1)](podman-artifact-add.1.md) | Add an OCI artifact to the local store |
25+
| extract | [podman-artifact-extract(1)](podman-artifact-extract.1.md) | Extract an OCI artifact to a local path |
2526
| inspect | [podman-artifact-inspect(1)](podman-artifact-inspect.1.md) | Inspect an OCI artifact |
2627
| ls | [podman-artifact-ls(1)](podman-artifact-ls.1.md) | List OCI artifacts in local store |
2728
| pull | [podman-artifact-pull(1)](podman-artifact-pull.1.md) | Pulls an artifact from a registry and stores it locally |

pkg/domain/entities/artifact.go

+9
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ type ArtifactAddOptions struct {
1414
ArtifactType string
1515
}
1616

17+
type ArtifactExtractOptions struct {
18+
// Title annotation value to extract only a single blob matching that name.
19+
// Conflicts with Digest. Optional.
20+
Title string
21+
// Digest of the blob to extract.
22+
// Conflicts with Title. Optional.
23+
Digest string
24+
}
25+
1726
type ArtifactInspectOptions struct {
1827
Remote bool
1928
}

pkg/domain/entities/engine_image.go

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
type ImageEngine interface { //nolint:interfacebloat
1212
ArtifactAdd(ctx context.Context, name string, paths []string, opts *ArtifactAddOptions) (*ArtifactAddReport, error)
13+
ArtifactExtract(ctx context.Context, name string, target string, opts *ArtifactExtractOptions) error
1314
ArtifactInspect(ctx context.Context, name string, opts ArtifactInspectOptions) (*ArtifactInspectReport, error)
1415
ArtifactList(ctx context.Context, opts ArtifactListOptions) ([]*ArtifactListReport, error)
1516
ArtifactPull(ctx context.Context, name string, opts ArtifactPullOptions) (*ArtifactPullReport, error)

pkg/domain/infra/abi/artifact.go

+13
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,16 @@ func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []str
172172
ArtifactDigest: artifactDigest,
173173
}, nil
174174
}
175+
176+
func (ir *ImageEngine) ArtifactExtract(ctx context.Context, name string, target string, opts *entities.ArtifactExtractOptions) error {
177+
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
178+
if err != nil {
179+
return err
180+
}
181+
extractOpt := &types.ExtractOptions{
182+
Digest: opts.Digest,
183+
Title: opts.Title,
184+
}
185+
186+
return artStore.Extract(ctx, name, target, extractOpt)
187+
}

pkg/domain/infra/tunnel/artifact.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99

1010
// TODO For now, no remote support has been added. We need the API to firm up first.
1111

12-
func ArtifactAdd(ctx context.Context, path, name string, opts entities.ArtifactAddOptions) error {
12+
func (ir *ImageEngine) ArtifactExtract(ctx context.Context, name string, target string, opts *entities.ArtifactExtractOptions) error {
1313
return fmt.Errorf("not implemented")
1414
}
1515

pkg/libartifact/store/store.go

+162
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"io/fs"
1112
"maps"
1213
"net/http"
1314
"os"
1415
"path/filepath"
16+
"strings"
1517

1618
"github.com/containers/common/libimage"
1719
"github.com/containers/image/v5/manifest"
@@ -254,6 +256,166 @@ func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, op
254256
return &artifactManifestDigest, nil
255257
}
256258

259+
// Inspect an artifact in a local store
260+
func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target string, options *libartTypes.ExtractOptions) error {
261+
if len(options.Digest) > 0 && len(options.Title) > 0 {
262+
return errors.New("cannot specify both digest and title")
263+
}
264+
if len(nameOrDigest) == 0 {
265+
return ErrEmptyArtifactName
266+
}
267+
268+
artifacts, err := as.getArtifacts(ctx, nil)
269+
if err != nil {
270+
return err
271+
}
272+
273+
arty, nameIsDigest, err := artifacts.GetByNameOrDigest(nameOrDigest)
274+
if err != nil {
275+
return err
276+
}
277+
name := nameOrDigest
278+
if nameIsDigest {
279+
name = arty.Name
280+
}
281+
282+
if len(arty.Manifest.Layers) == 0 {
283+
return fmt.Errorf("the artifact has no blobs, nothing to extract")
284+
}
285+
286+
ir, err := layout.NewReference(as.storePath, name)
287+
if err != nil {
288+
return err
289+
}
290+
imgSrc, err := ir.NewImageSource(ctx, as.SystemContext)
291+
if err != nil {
292+
return err
293+
}
294+
defer imgSrc.Close()
295+
296+
// check if dest is a dir to know if we can copy more than one blob
297+
destIsFile := true
298+
stat, err := os.Stat(target)
299+
if err == nil {
300+
destIsFile = !stat.IsDir()
301+
} else if !errors.Is(err, fs.ErrNotExist) {
302+
return err
303+
}
304+
305+
if destIsFile {
306+
var digest digest.Digest
307+
if len(arty.Manifest.Layers) > 1 {
308+
if len(options.Digest) == 0 && len(options.Title) == 0 {
309+
return fmt.Errorf("the artifact consists of several blobs and the target %q is not a directory and neither digest or title was specified to only copy a single blob", target)
310+
}
311+
digest, err = findDigest(arty, options)
312+
if err != nil {
313+
return err
314+
}
315+
} else {
316+
digest = arty.Manifest.Layers[0].Digest
317+
}
318+
319+
return copyImageBlobToFile(ctx, imgSrc, digest, target)
320+
}
321+
322+
if len(options.Digest) > 0 || len(options.Title) > 0 {
323+
digest, err := findDigest(arty, options)
324+
if err != nil {
325+
return err
326+
}
327+
// In case the digest is set we always use it as target name
328+
// so we do not have to get the actual title annotation form the blob.
329+
// Passing options.Title is enough because we know it is empty when digest
330+
// is set as we only allow either one.
331+
filename, err := generateArtifactBlobName(options.Title, digest)
332+
if err != nil {
333+
return err
334+
}
335+
return copyImageBlobToFile(ctx, imgSrc, digest, filepath.Join(target, filename))
336+
}
337+
338+
for _, l := range arty.Manifest.Layers {
339+
title := l.Annotations[specV1.AnnotationTitle]
340+
filename, err := generateArtifactBlobName(title, l.Digest)
341+
if err != nil {
342+
return err
343+
}
344+
err = copyImageBlobToFile(ctx, imgSrc, l.Digest, filepath.Join(target, filename))
345+
if err != nil {
346+
return err
347+
}
348+
}
349+
350+
return nil
351+
}
352+
353+
func generateArtifactBlobName(title string, digest digest.Digest) (string, error) {
354+
filename := title
355+
if len(filename) == 0 {
356+
// No filename given, use the digest. But because ":" is not a valid path char
357+
// on all platforms replace it with "-".
358+
filename = strings.ReplaceAll(digest.String(), ":", "-")
359+
}
360+
361+
// Important: A potentially malicious artifact could contain a title name with "/"
362+
// and could try via relative paths such as "../" try to overwrite files on the host
363+
// the user did not intend. As there is no use for directories in this path we
364+
// disallow all of them and not try to "make it safe" via securejoin or others.
365+
// We must use os.IsPathSeparator() as on Windows it checks both "\\" and "/".
366+
for i := 0; i < len(filename); i++ {
367+
if os.IsPathSeparator(filename[i]) {
368+
return "", fmt.Errorf("invalid name: %q cannot contain %c", filename, filename[i])
369+
}
370+
}
371+
return filename, nil
372+
}
373+
374+
func findDigest(arty *libartifact.Artifact, options *libartTypes.ExtractOptions) (digest.Digest, error) {
375+
var digest digest.Digest
376+
for _, l := range arty.Manifest.Layers {
377+
if options.Digest == l.Digest.String() {
378+
if len(digest.String()) > 0 {
379+
return digest, fmt.Errorf("more than one match for the digest %q", options.Digest)
380+
}
381+
digest = l.Digest
382+
}
383+
if len(options.Title) > 0 {
384+
if val, ok := l.Annotations[specV1.AnnotationTitle]; ok &&
385+
val == options.Title {
386+
if len(digest.String()) > 0 {
387+
return digest, fmt.Errorf("more than one match for the title %q", options.Title)
388+
}
389+
digest = l.Digest
390+
}
391+
}
392+
}
393+
if len(digest.String()) == 0 {
394+
if len(options.Title) > 0 {
395+
return digest, fmt.Errorf("no blob with the title %q", options.Title)
396+
}
397+
return digest, fmt.Errorf("no blob with the digest %q", options.Digest)
398+
}
399+
return digest, nil
400+
}
401+
402+
func copyImageBlobToFile(ctx context.Context, imgSrc types.ImageSource, digest digest.Digest, target string) error {
403+
src, _, err := imgSrc.GetBlob(ctx, types.BlobInfo{Digest: digest}, nil)
404+
if err != nil {
405+
return fmt.Errorf("failed to get artifact file: %w", err)
406+
}
407+
defer src.Close()
408+
dest, err := os.Create(target)
409+
if err != nil {
410+
return fmt.Errorf("failed to create target file: %w", err)
411+
}
412+
defer dest.Close()
413+
414+
// TODO use reflink is possible
415+
_, err = io.Copy(dest, src)
416+
return err
417+
}
418+
257419
// readIndex is currently unused but I want to keep this around until
258420
// the artifact code is more mature.
259421
func (as ArtifactStore) readIndex() (*specV1.Index, error) { //nolint:unused

pkg/libartifact/types/config.go

+7
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ type AddOptions struct {
99
Annotations map[string]string `json:"annotations,omitempty"`
1010
ArtifactType string `json:",omitempty"`
1111
}
12+
13+
type ExtractOptions struct {
14+
// Title annotation value to extract only a single blob matching that name. Optional.
15+
Title string
16+
// Digest of the blob to extract. Optional.
17+
Digest string
18+
}

test/NEW-IMAGES

+5
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@
1212
#
1313
# Format is one FQIN per line. Enumerate them below:
1414
#
15+
16+
quay.io/libpod/testartifact:20250206-single
17+
quay.io/libpod/testartifact:20250206-multi
18+
quay.io/libpod/testartifact:20250206-multi-no-title
19+
quay.io/libpod/testartifact:20250206-evil

0 commit comments

Comments
 (0)