Add --format to image scp

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 <hony.com@seznam.cz>
This commit is contained in:
Jan Rodák 2026-03-09 17:01:47 +01:00
parent e67778b4f4
commit da3c4aa21a
No known key found for this signature in database
GPG key ID: D458A9B20435C2BF
8 changed files with 88 additions and 20 deletions

View file

@ -38,6 +38,8 @@ var (
LogLevels = []string{"trace", "debug", "info", "warn", "warning", "error", "fatal", "panic"} LogLevels = []string{"trace", "debug", "info", "warn", "warning", "error", "fatal", "panic"}
// ValidSaveFormats is the list of support podman save formats // ValidSaveFormats is the list of support podman save formats
ValidSaveFormats = []string{define.OCIManifestDir, define.OCIArchive, define.V2s2ManifestDir, define.V2s2Archive} 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 type completeType int
@ -1711,6 +1713,11 @@ func AutocompleteImageSaveFormat(_ *cobra.Command, _ []string, _ string) ([]stri
return ValidSaveFormats, cobra.ShellCompDirectiveNoFileComp 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. // AutocompleteWaitCondition - Autocomplete wait condition options.
// -> "unknown", "configured", "created", "running", "stopped", "paused", "exited", "removing" // -> "unknown", "configured", "created", "running", "stopped", "paused", "exited", "removing"
func AutocompleteWaitCondition(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { func AutocompleteWaitCondition(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {

View file

@ -6,6 +6,7 @@ import (
"github.com/containers/podman/v6/cmd/podman/common" "github.com/containers/podman/v6/cmd/podman/common"
"github.com/containers/podman/v6/cmd/podman/registry" "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/containers/podman/v6/pkg/domain/entities"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.podman.io/common/pkg/ssh" "go.podman.io/common/pkg/ssh"
@ -30,6 +31,7 @@ var (
var ( var (
parentFlags []string parentFlags []string
quiet bool quiet bool
format string
) )
func init() { func init() {
@ -43,6 +45,10 @@ func init() {
func scpFlags(cmd *cobra.Command) { func scpFlags(cmd *cobra.Command) {
flags := cmd.Flags() flags := cmd.Flags()
flags.BoolVarP(&quiet, "quiet", "q", false, "Suppress the output") 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) { func scp(_ *cobra.Command, args []string) (finalErr error) {
@ -76,6 +82,7 @@ func scp(_ *cobra.Command, args []string) (finalErr error) {
scpOpts.ParentFlags = parentFlags scpOpts.ParentFlags = parentFlags
scpOpts.Quiet = quiet scpOpts.Quiet = quiet
scpOpts.SSHMode = sshEngine scpOpts.SSHMode = sshEngine
scpOpts.SaveFormat = format
_, err = registry.ImageEngine().Scp(registry.Context(), src, dst, scpOpts) _, err = registry.ImageEngine().Scp(registry.Context(), src, dst, scpOpts)
if err != nil { if err != nil {
return err return err

View file

@ -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. **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. 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 [GLOBAL OPTIONS]**
**podman image** *scp [OPTIONS] NAME[:TAG] [HOSTNAME::]* **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 ## 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** #### **--help**, **-h**
Print usage statement Print usage statement
@ -95,6 +103,16 @@ Storing signatures
Loaded image: docker.io/library/alpine:latest 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 ## 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)** **[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)**

View file

@ -95,6 +95,7 @@ my %Format_Option_Is_Special = map { $_ => 1 } (
'machine os upgrade', # " " " " 'machine os upgrade', # " " " "
'push', 'image push', 'manifest push', # oci | v2s* 'push', 'image push', 'manifest push', # oci | v2s*
'save', 'image save', # image formats (oci-*, ...) 'save', 'image save', # image formats (oci-*, ...)
'image scp', # image archive formats only
'inspect', # ambiguous (container/image) 'inspect', # ambiguous (container/image)
); );

View file

@ -31,6 +31,8 @@ type ScpExecuteTransferOptions struct {
Quiet bool Quiet bool
// SSHMode is the specified ssh.EngineMode which should be used // SSHMode is the specified ssh.EngineMode which should be used
SSHMode ssh.EngineMode 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 { type ScpExecuteTransferReport struct {
@ -42,11 +44,15 @@ type ScpExecuteTransferReport struct {
Dest *ScpTransferImageOptions Dest *ScpTransferImageOptions
// ParentFlags are the arguments to apply to the parent podman command when called via ssh // ParentFlags are the arguments to apply to the parent podman command when called via ssh
ParentFlags []string ParentFlags []string
// SaveFormat is the format to use for podman save when transferring (oci-archive or docker-archive)
SaveFormat string
} }
type ScpTransferOptions struct { type ScpTransferOptions struct {
// ParentFlags are the arguments to apply to the parent podman command when called. // ParentFlags are the arguments to apply to the parent podman command when called.
ParentFlags []string ParentFlags []string
// SaveFormat is the format for podman save (oci-archive or docker-archive)
SaveFormat string
} }
type ScpTransferReport struct{} type ScpTransferReport struct{}
@ -85,6 +91,8 @@ type ScpSaveToRemoteOptions struct {
Iden string Iden string
// SSHMode is the specified ssh.EngineMode which should be used // SSHMode is the specified ssh.EngineMode which should be used
SSHMode ssh.EngineMode 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{} type ScpSaveToRemoteReport struct{}

View file

@ -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 if report.LoadReport == nil && (report.Source != nil && report.Dest != nil) { // we need to execute the transfer
transferOpts := entities.ScpTransferOptions{} transferOpts := entities.ScpTransferOptions{}
transferOpts.ParentFlags = report.ParentFlags transferOpts.ParentFlags = report.ParentFlags
transferOpts.SaveFormat = report.SaveFormat
_, err := Transfer(ctx, *report.Source, *report.Dest, transferOpts) _, err := Transfer(ctx, *report.Source, *report.Dest, transferOpts)
if err != nil { if err != nil {
return nil, err return nil, err
@ -806,17 +807,20 @@ func Transfer(_ context.Context, source entities.ScpTransferImageOptions, dest e
} }
rep := entities.ScpTransferReport{} 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 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 // 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 var cmdSave *exec.Cmd
saveCommand := slices.Clone(parentFlags) saveCommand := slices.Clone(opts.ParentFlags)
loadCommand := slices.Clone(parentFlags) loadCommand := slices.Clone(opts.ParentFlags)
saveCommand = append(saveCommand, []string{"save"}...) saveCommand = append(saveCommand, "save")
if opts.SaveFormat != "" {
saveCommand = append(saveCommand, "--format", opts.SaveFormat)
}
loadCommand = append(loadCommand, []string{"load"}...) loadCommand = append(loadCommand, []string{"load"}...)
if source.Quiet { if source.Quiet {
saveCommand = append(saveCommand, "-q") 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 // 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 { func transferRootful(source entities.ScpTransferImageOptions, dest entities.ScpTransferImageOptions, podman string, opts entities.ScpTransferOptions) error {
basicCommand := make([]string, 0, len(parentFlags)+1) basicCommand := make([]string, 0, len(opts.ParentFlags)+1)
basicCommand = append(basicCommand, podman) 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, basicCommand...)
saveCommand = append(saveCommand, "save") saveCommand = append(saveCommand, "save")
if opts.SaveFormat != "" {
saveCommand = append(saveCommand, "--format", opts.SaveFormat)
}
loadCommand := make([]string, 0, len(basicCommand)+3) loadCommand := make([]string, 0, len(basicCommand)+3)
loadCommand = append(loadCommand, basicCommand...) loadCommand = append(loadCommand, basicCommand...)

View file

@ -97,6 +97,7 @@ func ExecuteTransfer(src, dst string, opts entities.ScpExecuteTransferOptions) (
saveToRemoteOpts.URL = sshInfo.URI[0] saveToRemoteOpts.URL = sshInfo.URI[0]
saveToRemoteOpts.Iden = sshInfo.Identities[0] saveToRemoteOpts.Iden = sshInfo.Identities[0]
saveToRemoteOpts.SSHMode = opts.SSHMode saveToRemoteOpts.SSHMode = opts.SSHMode
saveToRemoteOpts.Format = opts.SaveFormat
_, err = SaveToRemote(saveToRemoteOpts) _, err = SaveToRemote(saveToRemoteOpts)
if err != nil { if err != nil {
return nil, err return nil, err
@ -139,6 +140,9 @@ func ExecuteTransfer(src, dst string, opts entities.ScpExecuteTransferOptions) (
saveCmd := []string{podman} saveCmd := []string{podman}
saveCmd = append(saveCmd, opts.ParentFlags...) saveCmd = append(saveCmd, opts.ParentFlags...)
saveCmd = append(saveCmd, "save") saveCmd = append(saveCmd, "save")
if opts.SaveFormat != "" {
saveCmd = append(saveCmd, "--format", opts.SaveFormat)
}
if source.Quiet { if source.Quiet {
saveCmd = append(saveCmd, "-q") saveCmd = append(saveCmd, "-q")
} }
@ -183,6 +187,7 @@ func ExecuteTransfer(src, dst string, opts entities.ScpExecuteTransferOptions) (
rep.Source = &source rep.Source = &source
rep.Dest = &dest rep.Dest = &dest
rep.ParentFlags = opts.ParentFlags rep.ParentFlags = opts.ParentFlags
rep.SaveFormat = opts.SaveFormat
return &rep, nil // transfer needs to be done in ABI due to cross issues 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 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -81,6 +81,14 @@ verify_iid_and_name() {
run_podman rmi $fqin 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" { @test "podman image scp transfer" {
skip_if_remote "only applicable under local podman" skip_if_remote "only applicable under local podman"
@ -123,8 +131,9 @@ verify_iid_and_name() {
run_podman image scp $newname ${notme}@localhost:: run_podman image scp $newname ${notme}@localhost::
is "$output" "Copying blob .*Copying config.*Writing manifest" is "$output" "Copying blob .*Copying config.*Writing manifest"
# confirm that image was copied. FIXME: also try $PODMAN image inspect? # Confirm image was copied and manifest format is preserved (OCI).
_sudo "${PODMAN_CMD[@]}" image exists $newname 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 # Copy it back, this time using -q
run_podman untag $IMAGE $newname run_podman untag $IMAGE $newname
@ -149,23 +158,22 @@ verify_iid_and_name() {
# get foobar's ID, for an ID transfer test # get foobar's ID, for an ID transfer test
run_podman image inspect --format '{{.ID}}' foobar:123 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 # Clean up
_sudo "${PODMAN_CMD[@]}" image rm foobartwo _sudo "${PODMAN_CMD[@]}" image rm foobartwo
run_podman untag $IMAGE $newname run_podman untag $IMAGE $newname
# Negative test for nonexistent image. # Negative test for nonexistent image (output may include exit status on second line).
# FIXME: error message is 2 lines, the 2nd being "exit status 125".
# FIXME: is that fixable, or do we have to live with it?
nope="nope.nope/nonesuch:notag" nope="nope.nope/nonesuch:notag"
run_podman 125 image scp ${notme}@localhost::$nope 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:: 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 run_podman rmi foobar:123
} }