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
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"}
// 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) {

View File

@@ -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

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.
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)**

View File

@@ -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)
);

View File

@@ -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{}

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
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...)

View File

@@ -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
}

View File

@@ -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
}