Skip to content

add podman artifact extract #25238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions cmd/podman/artifact/extract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package artifact

import (
"github.com/containers/common/pkg/completion"
"github.com/containers/podman/v5/cmd/podman/common"
"github.com/containers/podman/v5/cmd/podman/registry"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/spf13/cobra"
)

var (
extractCmd = &cobra.Command{
Use: "extract [options] ARTIFACT PATH",
Short: "Extract an OCI artifact to a local path",
Long: "Extract the blobs of an OCI artifact to a local file or directory",
RunE: extract,
Args: cobra.ExactArgs(2),
ValidArgsFunction: common.AutocompleteArtifactAdd,
Example: `podman artifact Extract quay.io/myimage/myartifact:latest /tmp/foobar.txt
podman artifact Extract quay.io/myimage/myartifact:latest /home/paul/mydir`,
Annotations: map[string]string{registry.EngineMode: registry.ABIMode},
}
)

var (
extractOpts entities.ArtifactExtractOptions
)

func init() {
registry.Commands = append(registry.Commands, registry.CliCommand{
Command: extractCmd,
Parent: artifactCmd,
})
flags := extractCmd.Flags()

digestFlagName := "digest"
flags.StringVar(&extractOpts.Digest, digestFlagName, "", "Only extract blob with the given digest")
_ = extractCmd.RegisterFlagCompletionFunc(digestFlagName, completion.AutocompleteNone)

titleFlagName := "title"
flags.StringVar(&extractOpts.Title, titleFlagName, "", "Only extract blob with the given title")
_ = extractCmd.RegisterFlagCompletionFunc(titleFlagName, completion.AutocompleteNone)
}

func extract(cmd *cobra.Command, args []string) error {
err := registry.ImageEngine().ArtifactExtract(registry.Context(), args[0], args[1], &extractOpts)
if err != nil {
Comment on lines +46 to +47
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
err := registry.ImageEngine().ArtifactExtract(registry.Context(), args[0], args[1], &extractOpts)
if err != nil {
if err := registry.ImageEngine().ArtifactExtract(registry.Context(), args[0], args[1], &extractOpts); err != nil {

return err
}

return nil
}
83 changes: 83 additions & 0 deletions docs/source/markdown/podman-artifact-extract.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
% podman-artifact-extract 1


## WARNING: Experimental command
*This command is considered experimental and still in development. Inputs, options, and outputs are all
subject to change.*

## NAME
podman\-artifact\-extract - Extract an OCI artifact to a local path

## SYNOPSIS
**podman artifact extract** *artifact* *target*

## DESCRIPTION

Extract the blobs of an OCI artifact to a local file or directory.

If the target path is a file or does not exist, the artifact must either consist
of one blob (layer) or if it has multiple blobs (layers) then the **--digest** or
**--title** option must be used to select only a single blob. If the file already
exists it will be overwritten.

If the target is a directory (it must exist), all blobs will be copied to the
target directory. As the target file name the value from the `org.opencontainers.image.title`
annotation is used. If the annotation is missing, the target file name will be the
digest of the blob (with `:` replaced by `-` in the name).
If the target file already exists in the directory, it will be overwritten.

## OPTIONS

#### **--digest**=**digest**

When extracting blobs from the artifact only use the one with the specified digest.
If the target is a directory then the digest is always used as file name instead even
when the title annotation exists on the blob.
Conflicts with **--title**.

#### **--help**

Print usage statement.

#### **--title**=**title**

When extracting blobs from the artifact only use the one with the specified title.
It looks for the `org.opencontainers.image.title` annotation and compares that
against the given title.
Conflicts with **--digest**.

## EXAMPLES

Extract an artifact with a single blob

```
$ podman artifact extract quay.io/artifact/foobar1:test /tmp/myfile
```

Extract an artifact with multiple blobs

```
$ podman artifact extract quay.io/artifact/foobar2:test /tmp/mydir
$ ls /tmp/mydir
CONTRIBUTING.md README.md
```

Extract only a single blob from an artifact with multiple blobs

```
$ podman artifact extract --title README.md quay.io/artifact/foobar2:test /tmp/mydir
$ ls /tmp/mydir
README.md
```
Or using the digest instead of the title
```
$ podman artifact extract --digest sha256:c0594e012b17fd9e6548355ceb571a79613f7bb988d7d883f112513601ac6e9a quay.io/artifact/foobar2:test /tmp/mydir
$ ls /tmp/mydir
README.md
```

## SEE ALSO
**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)**

## HISTORY
Feb 2025, Originally compiled by Paul Holzinger <[email protected]>
1 change: 1 addition & 0 deletions docs/source/markdown/podman-artifact.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ from its local "artifact store".
| Command | Man Page | Description |
|---------|------------------------------------------------------------|--------------------------------------------------------------|
| add | [podman-artifact-add(1)](podman-artifact-add.1.md) | Add an OCI artifact to the local store |
| extract | [podman-artifact-extract(1)](podman-artifact-extract.1.md) | Extract an OCI artifact to a local path |
| inspect | [podman-artifact-inspect(1)](podman-artifact-inspect.1.md) | Inspect an OCI artifact |
| ls | [podman-artifact-ls(1)](podman-artifact-ls.1.md) | List OCI artifacts in local store |
| pull | [podman-artifact-pull(1)](podman-artifact-pull.1.md) | Pulls an artifact from a registry and stores it locally |
Expand Down
9 changes: 9 additions & 0 deletions pkg/domain/entities/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ type ArtifactAddOptions struct {
ArtifactType string
}

type ArtifactExtractOptions struct {
// Title annotation value to extract only a single blob matching that name.
// Conflicts with Digest. Optional.
Title string
// Digest of the blob to extract.
// Conflicts with Title. Optional.
Digest string
}

type ArtifactInspectOptions struct {
Remote bool
}
Expand Down
1 change: 1 addition & 0 deletions pkg/domain/entities/engine_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

type ImageEngine interface { //nolint:interfacebloat
ArtifactAdd(ctx context.Context, name string, paths []string, opts *ArtifactAddOptions) (*ArtifactAddReport, error)
ArtifactExtract(ctx context.Context, name string, target string, opts *ArtifactExtractOptions) error
ArtifactInspect(ctx context.Context, name string, opts ArtifactInspectOptions) (*ArtifactInspectReport, error)
ArtifactList(ctx context.Context, opts ArtifactListOptions) ([]*ArtifactListReport, error)
ArtifactPull(ctx context.Context, name string, opts ArtifactPullOptions) (*ArtifactPullReport, error)
Expand Down
13 changes: 13 additions & 0 deletions pkg/domain/infra/abi/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,16 @@ func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []str
ArtifactDigest: artifactDigest,
}, nil
}

func (ir *ImageEngine) ArtifactExtract(ctx context.Context, name string, target string, opts *entities.ArtifactExtractOptions) error {
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
if err != nil {
return err
}
extractOpt := &types.ExtractOptions{
Digest: opts.Digest,
Title: opts.Title,
}

return artStore.Extract(ctx, name, target, extractOpt)
}
2 changes: 1 addition & 1 deletion pkg/domain/infra/tunnel/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

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

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

Expand Down
162 changes: 162 additions & 0 deletions pkg/libartifact/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"maps"
"net/http"
"os"
"path/filepath"
"strings"

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

// Inspect an artifact in a local store
func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target string, options *libartTypes.ExtractOptions) error {
if len(options.Digest) > 0 && len(options.Title) > 0 {
return errors.New("cannot specify both digest and title")
}
if len(nameOrDigest) == 0 {
return ErrEmptyArtifactName
}

artifacts, err := as.getArtifacts(ctx, nil)
if err != nil {
return err
}

arty, nameIsDigest, err := artifacts.GetByNameOrDigest(nameOrDigest)
if err != nil {
return err
}
name := nameOrDigest
if nameIsDigest {
name = arty.Name
}

if len(arty.Manifest.Layers) == 0 {
return fmt.Errorf("the artifact has no blobs, nothing to extract")
}

ir, err := layout.NewReference(as.storePath, name)
if err != nil {
return err
}
imgSrc, err := ir.NewImageSource(ctx, as.SystemContext)
if err != nil {
return err
}
defer imgSrc.Close()

// check if dest is a dir to know if we can copy more than one blob
destIsFile := true
stat, err := os.Stat(target)
if err == nil {
Comment on lines +298 to +299
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
stat, err := os.Stat(target)
if err == nil {
if stat, err := os.Stat(target); err == nil {

destIsFile = !stat.IsDir()
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}

if destIsFile {
var digest digest.Digest
if len(arty.Manifest.Layers) > 1 {
if len(options.Digest) == 0 && len(options.Title) == 0 {
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)
}
digest, err = findDigest(arty, options)
if err != nil {
return err
}
} else {
digest = arty.Manifest.Layers[0].Digest
}

return copyImageBlobToFile(ctx, imgSrc, digest, target)
}

if len(options.Digest) > 0 || len(options.Title) > 0 {
digest, err := findDigest(arty, options)
if err != nil {
return err
}
// In case the digest is set we always use it as target name
// so we do not have to get the actual title annotation form the blob.
// Passing options.Title is enough because we know it is empty when digest
// is set as we only allow either one.
filename, err := generateArtifactBlobName(options.Title, digest)
if err != nil {
return err
}
return copyImageBlobToFile(ctx, imgSrc, digest, filepath.Join(target, filename))
}

for _, l := range arty.Manifest.Layers {
title := l.Annotations[specV1.AnnotationTitle]
filename, err := generateArtifactBlobName(title, l.Digest)
if err != nil {
return err
}
err = copyImageBlobToFile(ctx, imgSrc, l.Digest, filepath.Join(target, filename))
if err != nil {
Comment on lines +344 to +345
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
err = copyImageBlobToFile(ctx, imgSrc, l.Digest, filepath.Join(target, filename))
if err != nil {
if err := copyImageBlobToFile(ctx, imgSrc, l.Digest, filepath.Join(target, filename)); err != nil {

return err
}
}

return nil
}

func generateArtifactBlobName(title string, digest digest.Digest) (string, error) {
filename := title
if len(filename) == 0 {
// No filename given, use the digest. But because ":" is not a valid path char
// on all platforms replace it with "-".
filename = strings.ReplaceAll(digest.String(), ":", "-")
}

// Important: A potentially malicious artifact could contain a title name with "/"
// and could try via relative paths such as "../" try to overwrite files on the host
// the user did not intend. As there is no use for directories in this path we
// disallow all of them and not try to "make it safe" via securejoin or others.
// We must use os.IsPathSeparator() as on Windows it checks both "\\" and "/".
for i := 0; i < len(filename); i++ {
if os.IsPathSeparator(filename[i]) {
return "", fmt.Errorf("invalid name: %q cannot contain %c", filename, filename[i])
}
}
return filename, nil
}

func findDigest(arty *libartifact.Artifact, options *libartTypes.ExtractOptions) (digest.Digest, error) {
var digest digest.Digest
for _, l := range arty.Manifest.Layers {
if options.Digest == l.Digest.String() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: This probably can be structured as if layerMatchesOptions(…) { duplicate check; set digest/title }). This works fine as is, and it is short enough that it is not that necessary.

if len(digest.String()) > 0 {
return digest, fmt.Errorf("more than one match for the digest %q", options.Digest)
}
digest = l.Digest
}
if len(options.Title) > 0 {
if val, ok := l.Annotations[specV1.AnnotationTitle]; ok &&
val == options.Title {
if len(digest.String()) > 0 {
return digest, fmt.Errorf("more than one match for the title %q", options.Title)
}
digest = l.Digest
}
}
}
if len(digest.String()) == 0 {
if len(options.Title) > 0 {
return digest, fmt.Errorf("no blob with the title %q", options.Title)
}
return digest, fmt.Errorf("no blob with the digest %q", options.Digest)
}
return digest, nil
}

func copyImageBlobToFile(ctx context.Context, imgSrc types.ImageSource, digest digest.Digest, target string) error {
src, _, err := imgSrc.GetBlob(ctx, types.BlobInfo{Digest: digest}, nil)
if err != nil {
return fmt.Errorf("failed to get artifact file: %w", err)
}
defer src.Close()
dest, err := os.Create(target)
if err != nil {
return fmt.Errorf("failed to create target file: %w", err)
}
defer dest.Close()

// TODO use reflink is possible
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a long term todo, or something you would do as part of this PR?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is easy to do, but as usual I don't want to duplicate code and the c/image function is internal so I have to export it first then vendor again, etc... So not something I plan to do for this PR but I will be working on that soon enough.

_, err = io.Copy(dest, src)
return err
}

// readIndex is currently unused but I want to keep this around until
// the artifact code is more mature.
func (as ArtifactStore) readIndex() (*specV1.Index, error) { //nolint:unused
Expand Down
7 changes: 7 additions & 0 deletions pkg/libartifact/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ type AddOptions struct {
Annotations map[string]string `json:"annotations,omitempty"`
ArtifactType string `json:",omitempty"`
}

type ExtractOptions struct {
// Title annotation value to extract only a single blob matching that name. Optional.
Title string
// Digest of the blob to extract. Optional.
Digest string
}
Loading