-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
add podman artifact extract #25238
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
} |
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]> |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -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" | ||||||||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
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() { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: This probably can be structured as |
||||||||
if len(digest.String()) > 0 { | ||||||||
mtrmac marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
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 | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.