Skip to content

Commit

Permalink
Merge pull request #25238 from Luap99/artifact-extract
Browse files Browse the repository at this point in the history
add podman artifact extract
  • Loading branch information
openshift-merge-bot[bot] authored Feb 11, 2025
2 parents 4a0b230 + 3925a30 commit 2f261d1
Show file tree
Hide file tree
Showing 11 changed files with 519 additions and 1 deletion.
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 {
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 <pholzing@redhat.com>
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 {
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 {
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() {
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
_, 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

0 comments on commit 2f261d1

Please sign in to comment.