From da3c4aa21a3b0dbe62c506dfbe759443e5f3e698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Rod=C3=A1k?= Date: Mon, 9 Mar 2026 17:01:47 +0100 Subject: [PATCH] Add --format to image scp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --format (oci-archive, docker-archive) to pass through to podman save. Default is unchanged (no --format) so podman save uses its own default. Document that scp is not storage-to-storage and only archive formats are supported. Fixes: https://github.com/containers/podman/issues/28183 Fixes: https://issues.redhat.com/browse/RUN-4403 Signed-off-by: Jan Rodák --- cmd/podman/common/completion.go | 7 ++++++ cmd/podman/images/scp.go | 7 ++++++ docs/source/markdown/podman-image-scp.1.md | 18 +++++++++++++++ hack/xref-helpmsgs-manpages | 1 + pkg/domain/entities/scp.go | 8 +++++++ pkg/domain/infra/abi/images.go | 27 ++++++++++++++-------- pkg/domain/utils/scp.go | 14 ++++++++++- test/system/120-load.bats | 26 +++++++++++++-------- 8 files changed, 88 insertions(+), 20 deletions(-) diff --git a/cmd/podman/common/completion.go b/cmd/podman/common/completion.go index cafae7e4c6..4c55da05f8 100644 --- a/cmd/podman/common/completion.go +++ b/cmd/podman/common/completion.go @@ -38,6 +38,8 @@ var ( LogLevels = []string{"trace", "debug", "info", "warn", "warning", "error", "fatal", "panic"} // ValidSaveFormats is the list of support podman save formats ValidSaveFormats = []string{define.OCIManifestDir, define.OCIArchive, define.V2s2ManifestDir, define.V2s2Archive} + // ValidScpFormats is the list of formats for podman image scp (archive types only) + ValidScpFormats = []string{define.OCIArchive, define.V2s2Archive} ) type completeType int @@ -1711,6 +1713,11 @@ func AutocompleteImageSaveFormat(_ *cobra.Command, _ []string, _ string) ([]stri return ValidSaveFormats, cobra.ShellCompDirectiveNoFileComp } +// AutocompleteImageScpFormat - Autocomplete image scp format options (oci-archive, docker-archive). +func AutocompleteImageScpFormat(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return ValidScpFormats, cobra.ShellCompDirectiveNoFileComp +} + // AutocompleteWaitCondition - Autocomplete wait condition options. // -> "unknown", "configured", "created", "running", "stopped", "paused", "exited", "removing" func AutocompleteWaitCondition(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { diff --git a/cmd/podman/images/scp.go b/cmd/podman/images/scp.go index bb1b648248..bb822946a8 100644 --- a/cmd/podman/images/scp.go +++ b/cmd/podman/images/scp.go @@ -6,6 +6,7 @@ import ( "github.com/containers/podman/v6/cmd/podman/common" "github.com/containers/podman/v6/cmd/podman/registry" + "github.com/containers/podman/v6/cmd/podman/validate" "github.com/containers/podman/v6/pkg/domain/entities" "github.com/spf13/cobra" "go.podman.io/common/pkg/ssh" @@ -30,6 +31,7 @@ var ( var ( parentFlags []string quiet bool + format string ) func init() { @@ -43,6 +45,10 @@ func init() { func scpFlags(cmd *cobra.Command) { flags := cmd.Flags() flags.BoolVarP(&quiet, "quiet", "q", false, "Suppress the output") + + formatChoice := validate.Value(&format, common.ValidScpFormats...) + flags.Var(formatChoice, "format", "Format for `podman save` when creating the transfer archive ("+formatChoice.Choices()+"). Default is docker-archive when omitted.") + _ = cmd.RegisterFlagCompletionFunc("format", common.AutocompleteImageScpFormat) } func scp(_ *cobra.Command, args []string) (finalErr error) { @@ -76,6 +82,7 @@ func scp(_ *cobra.Command, args []string) (finalErr error) { scpOpts.ParentFlags = parentFlags scpOpts.Quiet = quiet scpOpts.SSHMode = sshEngine + scpOpts.SaveFormat = format _, err = registry.ImageEngine().Scp(registry.Context(), src, dst, scpOpts) if err != nil { return err diff --git a/docs/source/markdown/podman-image-scp.1.md b/docs/source/markdown/podman-image-scp.1.md index 33c6ba1233..8a000a9623 100644 --- a/docs/source/markdown/podman-image-scp.1.md +++ b/docs/source/markdown/podman-image-scp.1.md @@ -10,6 +10,8 @@ podman-image-scp - Securely copy an image from one host to another **podman image scp** copies container images between hosts on a network. This command can copy images to the remote host or from the remote host as well as between two remote hosts. Note: `::` is used to specify the image name depending on Podman is saving or loading. Images can also be transferred from rootful to rootless storage on the same machine without using sshd. This feature is not supported on the remote client, including Mac and Windows (excluding WSL2) machines. +This is not a direct storage-to-storage copy. The image is saved to an archive (using **podman save**), the archive file is transferred (e.g., over SSH), and then loaded on the destination. As a result, digest references to the original compressed blobs are not preserved (e.g., **podman pull** *image*@*digest* followed by **podman image scp** and then inspecting by that digest may not work). For regular workflows, using a registry (push from source, pull on destination) is often preferable. + **podman image scp [GLOBAL OPTIONS]** **podman image** *scp [OPTIONS] NAME[:TAG] [HOSTNAME::]* @@ -20,6 +22,12 @@ Note: `::` is used to specify the image name depending on Podman is saving or lo ## OPTIONS +#### **--format**=*format* + +Format passed to **podman save** when creating the transfer archive. Allowed values are **oci-archive** and **docker-archive**. If omitted, **podman save** uses its default (docker-archive). + +Only the **oci-archive** and **docker-archive** archive (tar) formats are supported. Directory formats (**oci-dir**, **docker-dir**) are not supported because the transfer sends a single file; the remote path does not support directory layouts. + #### **--help**, **-h** Print usage statement @@ -95,6 +103,16 @@ Storing signatures Loaded image: docker.io/library/alpine:latest ``` +Copy image to rootful storage with OCI archive format: +``` +$ podman image scp --format oci-archive quay.io/fedora/fedora:43 root@localhost:: +``` + +Copy image to remote host (uses default format when **--format** is omitted): +``` +$ podman image scp alpine root@myserver:: +``` + ## SEE ALSO **[podman(1)](podman.1.md)**, **[podman-load(1)](podman-load.1.md)**, **[podman-save(1)](podman-save.1.md)**, **[podman-remote(1)](podman-remote.1.md)**, **[podman-system-connection-add(1)](podman-system-connection-add.1.md)**, **[containers.conf(5)](https://github.com/containers/container-libs/blob/main/common/docs/containers.conf.5.md)**, **[containers-transports(5)](https://github.com/containers/image/blob/main/docs/containers-transports.5.md)** diff --git a/hack/xref-helpmsgs-manpages b/hack/xref-helpmsgs-manpages index 40cc32509d..26b7fb6282 100755 --- a/hack/xref-helpmsgs-manpages +++ b/hack/xref-helpmsgs-manpages @@ -95,6 +95,7 @@ my %Format_Option_Is_Special = map { $_ => 1 } ( 'machine os upgrade', # " " " " 'push', 'image push', 'manifest push', # oci | v2s* 'save', 'image save', # image formats (oci-*, ...) + 'image scp', # image archive formats only 'inspect', # ambiguous (container/image) ); diff --git a/pkg/domain/entities/scp.go b/pkg/domain/entities/scp.go index 7fb1ba851e..7b9c522784 100644 --- a/pkg/domain/entities/scp.go +++ b/pkg/domain/entities/scp.go @@ -31,6 +31,8 @@ type ScpExecuteTransferOptions struct { Quiet bool // SSHMode is the specified ssh.EngineMode which should be used SSHMode ssh.EngineMode + // SaveFormat is the format for podman save (oci-archive or docker-archive). Empty means default of podman save (docker-archive). + SaveFormat string } type ScpExecuteTransferReport struct { @@ -42,11 +44,15 @@ type ScpExecuteTransferReport struct { Dest *ScpTransferImageOptions // ParentFlags are the arguments to apply to the parent podman command when called via ssh ParentFlags []string + // SaveFormat is the format to use for podman save when transferring (oci-archive or docker-archive) + SaveFormat string } type ScpTransferOptions struct { // ParentFlags are the arguments to apply to the parent podman command when called. ParentFlags []string + // SaveFormat is the format for podman save (oci-archive or docker-archive) + SaveFormat string } type ScpTransferReport struct{} @@ -85,6 +91,8 @@ type ScpSaveToRemoteOptions struct { Iden string // SSHMode is the specified ssh.EngineMode which should be used SSHMode ssh.EngineMode + // Format is the save format (oci-archive or docker-archive). Empty means default of podman save (docker-archive). + Format string } type ScpSaveToRemoteReport struct{} diff --git a/pkg/domain/infra/abi/images.go b/pkg/domain/infra/abi/images.go index cacb4b90c7..55c84458b1 100644 --- a/pkg/domain/infra/abi/images.go +++ b/pkg/domain/infra/abi/images.go @@ -788,6 +788,7 @@ func (ir *ImageEngine) Scp(ctx context.Context, src, dst string, opts entities.I if report.LoadReport == nil && (report.Source != nil && report.Dest != nil) { // we need to execute the transfer transferOpts := entities.ScpTransferOptions{} transferOpts.ParentFlags = report.ParentFlags + transferOpts.SaveFormat = report.SaveFormat _, err := Transfer(ctx, *report.Source, *report.Dest, transferOpts) if err != nil { return nil, err @@ -806,17 +807,20 @@ func Transfer(_ context.Context, source entities.ScpTransferImageOptions, dest e } rep := entities.ScpTransferReport{} if rootless.IsRootless() && (len(dest.User) == 0 || dest.User == "root") { // if we are rootless and do not have a destination user we can just use sudo - return &rep, transferRootless(source, dest, podman, opts.ParentFlags) + return &rep, transferRootless(source, dest, podman, opts) } - return &rep, transferRootful(source, dest, podman, opts.ParentFlags) + return &rep, transferRootful(source, dest, podman, opts) } // TransferRootless creates new podman processes using exec.Command and sudo, transferring images between the given source and destination users -func transferRootless(source entities.ScpTransferImageOptions, dest entities.ScpTransferImageOptions, podman string, parentFlags []string) error { +func transferRootless(source entities.ScpTransferImageOptions, dest entities.ScpTransferImageOptions, podman string, opts entities.ScpTransferOptions) error { var cmdSave *exec.Cmd - saveCommand := slices.Clone(parentFlags) - loadCommand := slices.Clone(parentFlags) - saveCommand = append(saveCommand, []string{"save"}...) + saveCommand := slices.Clone(opts.ParentFlags) + loadCommand := slices.Clone(opts.ParentFlags) + saveCommand = append(saveCommand, "save") + if opts.SaveFormat != "" { + saveCommand = append(saveCommand, "--format", opts.SaveFormat) + } loadCommand = append(loadCommand, []string{"load"}...) if source.Quiet { saveCommand = append(saveCommand, "-q") @@ -854,14 +858,17 @@ func transferRootless(source entities.ScpTransferImageOptions, dest entities.Scp } // transferRootful creates new podman processes using exec.Command and a new uid/gid alongside a cleared environment -func transferRootful(source entities.ScpTransferImageOptions, dest entities.ScpTransferImageOptions, podman string, parentFlags []string) error { - basicCommand := make([]string, 0, len(parentFlags)+1) +func transferRootful(source entities.ScpTransferImageOptions, dest entities.ScpTransferImageOptions, podman string, opts entities.ScpTransferOptions) error { + basicCommand := make([]string, 0, len(opts.ParentFlags)+1) basicCommand = append(basicCommand, podman) - basicCommand = append(basicCommand, parentFlags...) + basicCommand = append(basicCommand, opts.ParentFlags...) - saveCommand := make([]string, 0, len(basicCommand)+4) + saveCommand := make([]string, 0, len(basicCommand)+6) saveCommand = append(saveCommand, basicCommand...) saveCommand = append(saveCommand, "save") + if opts.SaveFormat != "" { + saveCommand = append(saveCommand, "--format", opts.SaveFormat) + } loadCommand := make([]string, 0, len(basicCommand)+3) loadCommand = append(loadCommand, basicCommand...) diff --git a/pkg/domain/utils/scp.go b/pkg/domain/utils/scp.go index 370d159c4a..4dd3725ca1 100644 --- a/pkg/domain/utils/scp.go +++ b/pkg/domain/utils/scp.go @@ -97,6 +97,7 @@ func ExecuteTransfer(src, dst string, opts entities.ScpExecuteTransferOptions) ( saveToRemoteOpts.URL = sshInfo.URI[0] saveToRemoteOpts.Iden = sshInfo.Identities[0] saveToRemoteOpts.SSHMode = opts.SSHMode + saveToRemoteOpts.Format = opts.SaveFormat _, err = SaveToRemote(saveToRemoteOpts) if err != nil { return nil, err @@ -139,6 +140,9 @@ func ExecuteTransfer(src, dst string, opts entities.ScpExecuteTransferOptions) ( saveCmd := []string{podman} saveCmd = append(saveCmd, opts.ParentFlags...) saveCmd = append(saveCmd, "save") + if opts.SaveFormat != "" { + saveCmd = append(saveCmd, "--format", opts.SaveFormat) + } if source.Quiet { saveCmd = append(saveCmd, "-q") } @@ -183,6 +187,7 @@ func ExecuteTransfer(src, dst string, opts entities.ScpExecuteTransferOptions) ( rep.Source = &source rep.Dest = &dest rep.ParentFlags = opts.ParentFlags + rep.SaveFormat = opts.SaveFormat return &rep, nil // transfer needs to be done in ABI due to cross issues } @@ -302,7 +307,14 @@ func SaveToRemote(opts entities.ScpSaveToRemoteOptions) (*entities.ScpSaveToRemo return nil, err } - _, err = ssh.Exec(&ssh.ConnectionExecOptions{Host: opts.URL.String(), Identity: opts.Iden, Port: port, User: opts.URL.User, Args: []string{"podman", "image", "save", opts.Image, "--format", "oci-archive", "--output", remoteFile}}, opts.SSHMode) + saveArgs := []string{"podman", "image", "save", opts.Image} + if opts.Format != "" { + saveArgs = append(saveArgs, "--format", opts.Format) + } + + saveArgs = append(saveArgs, "--output", remoteFile) + + _, err = ssh.Exec(&ssh.ConnectionExecOptions{Host: opts.URL.String(), Identity: opts.Iden, Port: port, User: opts.URL.User, Args: saveArgs}, opts.SSHMode) if err != nil { return nil, err } diff --git a/test/system/120-load.bats b/test/system/120-load.bats index 843b2425c3..5656cbcdbc 100644 --- a/test/system/120-load.bats +++ b/test/system/120-load.bats @@ -81,6 +81,14 @@ verify_iid_and_name() { run_podman rmi $fqin } + +@test "podman image scp --format invalid" { + skip_if_remote "only applicable under local podman" + + run_podman 125 image scp --format invalid-format $IMAGE somehost:: + assert "$output" =~ "Error:.*invalid-format.*is not a valid value" "invalid --format is rejected" +} + @test "podman image scp transfer" { skip_if_remote "only applicable under local podman" @@ -123,8 +131,9 @@ verify_iid_and_name() { run_podman image scp $newname ${notme}@localhost:: is "$output" "Copying blob .*Copying config.*Writing manifest" - # confirm that image was copied. FIXME: also try $PODMAN image inspect? - _sudo "${PODMAN_CMD[@]}" image exists $newname + # Confirm image was copied and manifest format is preserved (OCI). + run _sudo "${PODMAN_CMD[@]}" image inspect --format '{{.ManifestType}}' $newname + assert "$output" == "application/vnd.docker.distribution.manifest.v2+json" "destination image has Docker manifest type (default) after transfer" # Copy it back, this time using -q run_podman untag $IMAGE $newname @@ -149,23 +158,22 @@ verify_iid_and_name() { # get foobar's ID, for an ID transfer test run_podman image inspect --format '{{.ID}}' foobar:123 - run_podman image scp $output ${notme}@localhost::foobartwo + run_podman image scp --format oci-archive $output ${notme}@localhost::foobartwo - _sudo "${PODMAN_CMD[@]}" image exists foobartwo + run _sudo "${PODMAN_CMD[@]}" image inspect --format '{{.ManifestType}}' foobartwo + assert "$output" == "application/vnd.oci.image.manifest.v1+json" "destination image has OCI manifest type" # Clean up _sudo "${PODMAN_CMD[@]}" image rm foobartwo run_podman untag $IMAGE $newname - # Negative test for nonexistent image. - # FIXME: error message is 2 lines, the 2nd being "exit status 125". - # FIXME: is that fixable, or do we have to live with it? + # Negative test for nonexistent image (output may include exit status on second line). nope="nope.nope/nonesuch:notag" run_podman 125 image scp ${notme}@localhost::$nope - is "$output" "Error: $nope: image not known.*" "Pulling nonexistent image" + assert "$output" =~ "Error:.*$nope.*image not known" "Pulling nonexistent image" run_podman 125 image scp $nope ${notme}@localhost:: - is "$output" "Error: $nope: image not known.*" "Pushing nonexistent image" + assert "$output" =~ "Error:.*$nope.*image not known" "Pushing nonexistent image" run_podman rmi foobar:123 }